Webhooks
When you enqueue a large render as an async batch job, you don’t hold a request open waiting for it — you get a jobId back immediately. Webhooks are how the server tells you what happened: it POSTs to a callback URL you provide as each document renders, and once more when the job finishes.
For the end-to-end flow with code in all seven languages, see Run renders in the background.
Registering a callback URL
Section titled “Registering a callback URL”Include a callbackUrl when you enqueue the job. The server delivers all callbacks for that job to that URL. There’s nothing to register ahead of time — the URL travels with the request.
The two payload shapes
Section titled “The two payload shapes”The server sends up to N + 1 callbacks for a batch of N documents: one progress callback per successfully rendered document, then one completion callback at the end.
Distinguish them by the presence of document: progress callbacks carry it, the completion callback doesn’t. Every SDK ships a parser that does this for you and validates the full shape before dispatching, so a malformed payload raises a decode error rather than being silently mis-parsed.
Progress callback
Section titled “Progress callback”One POST per document that renders successfully:
{ "jobId": "6b1c9f42-0d3e-4a58-b7c1-9e2f8a4d5c30", "processed": 3, // documents completed so far "requestedCount": 10, // total documents in the batch "documentIndex": 7, // zero-based input position of THIS document "document": { /* the rendered document's metadata */ }}Documents render in parallel and callbacks are delivered concurrently, so they arrive out of input order. Use documentIndex — never the order callbacks arrive in — to correlate each rendered document back to the input that produced it.
Pass includeDocument: true at enqueue time and each progress callback’s document carries the PDF inline as Base64.
Completion callback
Section titled “Completion callback”One final POST when the job reaches a terminal state:
{ "jobId": "6b1c9f42-0d3e-4a58-b7c1-9e2f8a4d5c30", "state": "completed", // lifecycle: completed | failed "status": "partial", // outcome: ok | partial | failed | insufficient_credit "renderedCount": 9, "requestedCount": 10, "missingCount": 1, // requestedCount − renderedCount "message": "9 of 10 document(s) rendered; 1 skipped.", "issues": [ /* per-document validation & render issues */ ]}state vs status — a common confusion point
Section titled “state vs status — a common confusion point”These are two distinct fields answering different questions:
stateis the job’s lifecycle: did the job finish running? On the completion callback it’s alwayscompletedorfailed— neverpending, since the callback only fires at a terminal state.statusis the render outcome: of the documents that ran, how did they turn out?ok(all rendered),partial(some rendered, some didn’t),failed(nothing rendered), orinsufficient_credit(stopped when credit ran out).
A job can be state: "completed" and status: "partial" at the same time — the job ran to completion, but not every document rendered. Always read both.
Delivery semantics
Section titled “Delivery semantics”- Concurrent, not sequential. Deliveries are drained from a queue with bounded parallelism (16 in flight by default), so callbacks for one job can arrive out of order — including
processedvalues. Don’t treatprocessedas monotonically increasing on arrival; treat it as a snapshot count. Correlate ondocumentIndex. - Retried up to 5 times with exponential backoff (2 s, then 4 s, 8 s, 16 s) whenever your endpoint returns a non-2xx status, times out, or errors. Any 2xx stops the retries.
- ~30-second timeout per attempt. Respond quickly; don’t do heavy work synchronously in the handler.
- Best-effort, not guaranteed. Delivery is decoupled from rendering so a slow endpoint can’t gate document throughput. Under extreme load the delivery queue can shed its oldest pending callbacks, and after 5 failed attempts a callback is abandoned. The job-status endpoint is the authoritative signal — not the webhook.
- No auth header. Callbacks carry no
Authorizationheader (see securing your endpoint). - Failures get no progress callback. A document that fails to render produces no progress callback. Detect missing documents at completion by comparing
renderedCount < requestedCount; the completion callback’sissuesexplain why each one is missing.
Securing your endpoint
Section titled “Securing your endpoint”Because callbacks carry no auth header, anyone who discovers your callback URL could POST fake payloads to it. Protect it by making the URL itself unguessable:
- Embed a secret in the URL you register — a query token (
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 secret aren’t observable in transit.
- Consider a per-job token, checked against the
jobIdin the payload, so a leaked URL is only usable for the job it belongs to.
The SDK parsers help too: they validate the full expected shape before dispatching, so a payload matching neither the progress nor the completion shape raises a decode error instead of being mis-parsed into a plausible-looking completion.
Parsing a callback
Section titled “Parsing a callback”Each SDK exposes one function that returns the right typed object:
| SDK | Function | Returns |
|---|---|---|
| Python | parse_callback(payload) |
RenderProgress | RenderCompletion |
| TypeScript | parseCallback(payload) |
RenderProgress | RenderCompletion |
| Java | RenderCallback.parse(payload) |
sealed RenderCallback (exhaustive switch) |
| C# | RenderCallback.Parse(json) |
abstract RenderCallback (pattern match) |
| Ruby | Pagr.parse_callback(payload) |
Pagr::RenderProgress or Pagr::RenderCompletion |
| C++ | pagr::parse_callback(json) |
std::variant<RenderProgress, RenderCompletion> |
The Python SDK also ships a LocalWebhookReceiver — a loopback HTTP server for local development, examples and tests (pip install pagr[webhook]). It’s not for production traffic, and no other SDK bundles a receiver: wire the parser into whichever HTTP framework you already use.
See Run renders in the background for a handler in each language.
Polling instead
Section titled “Polling instead”Webhooks need a public URL the Pagr server can reach. If that’s inconvenient — local development, or a backend with no inbound endpoint — poll instead. It’s also the authoritative signal, so use it for anything you must not miss.
Call GET /v1/render/jobs/{jobId} on an interval (every couple of seconds is fine). It returns the same state / status split, the same counts, and the same issues. Stop polling once state is completed or failed. The two surfaces parse into the same model, so switching between them costs nothing.
Every SDK also has a wait_for_job helper that wraps the loop and treats an unrecognised state as terminal, so it can never spin forever.
Related articles
Section titled “Related articles”- Run renders in the background — the whole flow, step by step, in every SDK.
- Render a batch asynchronously — the enqueue and job-status endpoints in full.
- Choose a rendering shape — where the async shape fits among the other three.
- Errors — the
QueueFull(503) back-pressure error, and every other status code. - Render Events — the in-app log of render operations.
- SDKs — the SDKs parse callback payloads into typed objects for you.
