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.
- Signed, not bearer-authenticated. There’s no
Authorizationheader on a callback — instead every callback carries an HMAC signature inX-Pagr-Signaturethat proves it came from Pagr and hasn’t been replayed. Verify it before you act on the payload. - 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.
Delivery headers
Section titled “Delivery headers”Every callback carries three Pagr headers alongside the JSON body:
| Header | Value | What it’s for |
|---|---|---|
X-Pagr-Signature |
t=<unix seconds>,v1=<hex>[,v1=<hex>] |
The HMAC to verify. This is the callback’s only credential. |
X-Pagr-Event |
render.progress, render.completed or render.failed |
Which callback this is, without inspecting the body. render.progress is the progress shape; render.completed and render.failed are both the completion shape, split by its state. |
X-Pagr-Delivery |
A UUID, stable for one logical delivery | Retries repeat it, so it’s your dedup key. |
Every SDK exports these three names as constants, so you don’t hard-code the strings.
Verifying the signature
Section titled “Verifying the signature”Anyone who learns your callback URL can POST to it. The signature is what separates a genuine callback from a forgery, so verify before you act on the payload — parse afterwards, never before.
Where the secret comes from
Section titled “Where the secret comes from”Each organisation has one webhook signing secret (a whsec_… value), found in the workspace under Settings → API Keys next to your API keys. It’s issued the first time you look at it, and you can rotate it there. See API Keys.
The header
Section titled “The header”X-Pagr-Signature: t=1754899200,v1=bcaa0dced1702951e44a0c10c9729c853d59433fbb954a8c299e743abd89b2bftis the Unix timestamp, in seconds, at which this attempt was signed.- Each
v1isHMAC-SHA256(secret, "<t>.<raw body>"), in lowercase hex. The signed material is the timestamp, a literal., then the request body — so the timestamp can’t be tampered with independently of the body.
Every attempt is signed afresh with the time it was sent, so a retry that lands twenty minutes later still arrives inside a receiver’s tolerance window rather than looking like a replay.
What your receiver must check
Section titled “What your receiver must check”The SDK helpers below do all of this for you. If you’re verifying by hand, all five matter:
- Recompute the HMAC over
"<t>.<raw body>"with your signing secret and compare it, as lowercase hex, to the header’sv1values. - Try every
v1. More than one appears during a rotation (see below) — accept the callback if any of them matches, and stop at the first hit. - Reject a stale
t. Iftis further from your own clock than a tolerance you choose — the SDKs default to 300 seconds, in either direction — reject the callback. The timestamp is inside the signed material precisely so you can do this: an HMAC proves a body came from Pagr, but on its own it does nothing to stop someone re-POSTing a captured callback later. - Compare in constant time. A byte-by-byte early-exit comparison leaks, through its timing, how much of a guessed signature was correct — which is enough to recover a valid one. Use your platform’s fixed-time comparison.
- Ignore signature versions you don’t recognise. A future
v2=alongsidev1=must not read as a malformed header.
Treat any failure as “not from Pagr”: respond 400 and don’t process the payload. The SDK helpers raise rather than returning a boolean, so a forged callback can’t slip through a dropped return value.
Secret rotation is non-breaking
Section titled “Secret rotation is non-breaking”When you rotate the secret, the one it replaced keeps signing alongside it for a 24-hour grace period. During that window the header carries two v1 values — one per secret, active first — so a receiver still holding the old secret verifies successfully while you deploy the new one. That’s why rule 2 above says to try every v1 rather than only the first.
Still make the URL hard to guess
Section titled “Still make the URL hard to guess”Signature verification is the mechanism; an unguessable URL is worthwhile defence-in-depth on top of it, because it keeps junk traffic away from your handler in the first place. It is not a substitute — a URL can leak through logs, proxies and referrers, and an attacker who has it can forge payloads that verification would have caught.
- Always use HTTPS, so neither the URL nor the payload is observable in transit.
- Embed a random token in the URL you register — a query parameter (
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. - Consider a per-job token, checked against the
jobIdin the payload, so a leaked URL is only usable for the job it belongs to.
Parsing a callback
Section titled “Parsing a callback”Each SDK ships two entry points: a verify-only helper, and a combined one that verifies and then parses. Prefer the combined one — it takes the raw body, decodes the JSON itself, and so can’t be called in the wrong order or forgotten.
| SDK | Verify and parse (preferred) | Verify only |
|---|---|---|
| Python | parse_signed_callback(body, signature_header, secret) |
verify_signature(body, signature_header, secret) |
| TypeScript | parseSignedCallback(body, signatureHeader, secret) |
verifySignature(body, signatureHeader, secret) |
| Java | Webhooks.parseSignedCallback(body, signatureHeader, secret) |
Webhooks.verifySignature(body, signatureHeader, secret) |
| C# | WebhookSignature.ParseSignedCallback(rawBody, signatureHeader, secret) |
WebhookSignature.Verify(rawBody, signatureHeader, secret) |
| Ruby | Pagr.parse_signed_callback(body, signature_header, secret) |
Pagr.verify_signature(body, signature_header, secret) |
| C++ | pagr::webhooks::parse_signed_callback(body, signature_header, secret) |
pagr::webhooks::verify_signature(body, signature_header, secret) |
Each also takes an optional tolerance (and a clock override, for tests). A verification failure raises PagrSignatureError / PagrSignatureException — a subclass of the SDK’s base error type, so an existing catch on that base still catches it. A blank secret raises the language’s own argument error instead, so a receiver with an unset environment variable is never mistaken for a forged callback.
Once verified, the callback comes back as the right typed object for its shape:
| SDK | Returns | Parse without verifying |
|---|---|---|
| Python | RenderProgress | RenderCompletion |
parse_callback(payload) |
| TypeScript | RenderProgress | RenderCompletion |
parseCallback(payload) |
| Java | sealed RenderCallback (exhaustive switch) |
RenderCallback.parse(payload) |
| C# | abstract RenderCallback (pattern match) |
RenderCallback.Parse(json) |
| Ruby | Pagr::RenderProgress or Pagr::RenderCompletion |
Pagr.parse_callback(payload) |
| C++ | std::variant<RenderProgress, RenderCompletion> |
pagr::webhooks::parse_callback(json) |
The right-hand column is the older parse-only form. It’s still there for callbacks you’ve already verified, but on a raw request it skips the signature check entirely — reach for the signed variants instead. Either way the parser validates the full expected shape before dispatching, so a payload matching neither the progress nor the completion shape raises a decode error rather than being mis-parsed into a plausible-looking completion.
The SDKs are parser-only — none of them bundles an HTTP server to receive callbacks. Wire the parser into whichever HTTP framework you already use.
See Run renders in the background for a verifying 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. - API Keys — where the webhook signing secret lives, and how to rotate it.
- Render Events — the in-app log of render operations.
- SDKs — the SDKs parse callback payloads into typed objects for you.
