Skip to content

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.

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

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.

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:

  • state is the job’s lifecycle: did the job finish running? On the completion callback it’s always completed or failed — never pending, since the callback only fires at a terminal state.
  • status is 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), or insufficient_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.

  • 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 processed values. Don’t treat processed as monotonically increasing on arrival; treat it as a snapshot count. Correlate on documentIndex.
  • 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 Authorization header on a callback — instead every callback carries an HMAC signature in X-Pagr-Signature that 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’s issues explain why each one is missing.

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.

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.

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.

X-Pagr-Signature: t=1754899200,v1=bcaa0dced1702951e44a0c10c9729c853d59433fbb954a8c299e743abd89b2bf
  • t is the Unix timestamp, in seconds, at which this attempt was signed.
  • Each v1 is HMAC-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.

The SDK helpers below do all of this for you. If you’re verifying by hand, all five matter:

  1. Recompute the HMAC over "<t>.<raw body>" with your signing secret and compare it, as lowercase hex, to the header’s v1 values.
  2. 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.
  3. Reject a stale t. If t is 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.
  4. 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.
  5. Ignore signature versions you don’t recognise. A future v2= alongside v1= 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.

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.

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 jobId in the payload, so a leaked URL is only usable for the job it belongs to.

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.

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.