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 information in the same parsed shape, so choosing between them is an infrastructure decision, not an API one.

  • 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.
  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, so your handler must be idempotent.

  4. Parse each callback body with the SDK’s parser.

    It inspects the payload’s shape 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.

Terminal window
curl -X POST "https://pagr-prd-api-public.azurewebsites.net/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" }

Your endpoint gets a plain JSON POST. Hand the decoded body to the SDK’s parser and it returns the right typed object.

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

Webhooks need a public URL Pagr can reach. Polling needs nothing. Both give you the same state / status split, the same counts, and the same issues — parsed into the same model — so switching costs nothing.

Terminal window
# Poll every couple of seconds until state is terminal
while :; do
state=$(curl -s "https://pagr-prd-api-public.azurewebsites.net/v1/render/jobs/$JOB_ID" \
-H "Authorization: Bearer pagr_prod_xxxxxxxx" | jq -r .state)
[ "$state" = "pending" ] || break
sleep 2
done
echo "Finished: $state"

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. Anyone who discovers your callback URL could POST fake payloads at it, so make the URL itself the secret:

  • Embed an unguessable token — a query parameter (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 token aren’t observable in transit.
  • 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.

  • 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 — key your processing on jobId + documentIndex and make a repeat a no-op.
  • 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. The counts stay exact, so trust renderedCount / missingCount and treat issues as a diagnostic sample.
  • 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.