PDF and reporting

Delivering report PDFs without holding a request open

Rendering one report is a solved problem. Delivering four thousand of them on the first of the month is not. The job outlives the HTTP request, the platform cuts the connection, and the retry produces a second charge, a second file or a second email.

5 min readPublished Updated
What you actually see

A 502 or 504 from your own gateway while the render actually succeeded; duplicated report emails after a retry; or a queue that drains fine at 10 reports and collapses at 1,000.

Reproduce it

  1. 01Generate a report that takes longer than your platform's request ceiling — serverless functions commonly cut well below a browser's worst case — and observe that the caller sees a failure for work that completed.
  2. 02Retry that request and check whether your system produced one artifact or two.
  3. 03Run the month-end batch at real concurrency and watch memory rather than latency: browser jobs fail by exhaustion, not by slowing down.
The DIY version

Start with the smallest thing that works

Accept the job, return immediately, render in a background worker and notify when the file exists. This is the correct shape — and it is a queue, with everything a queue implies.

report-queue.mts
// A chave de idempotência é do *pedido*, não da tentativa. Sem ela, o
// retry do cliente vira um segundo relatório e uma segunda cobrança.
const jobKey = `report:${tenantId}:${periodStart}`;

await queue.add(
  "monthly-report",
  { tenantId, periodStart },
  { jobId: jobKey, attempts: 3, backoff: { type: "exponential", delay: 30_000 } },
);

// No worker:
//   1. renderiza o PDF
//   2. sobe para o bucket
//   3. só então marca entregue e dispara o webhook
// Inverter 2 e 3 anuncia um arquivo que ainda não existe.
Where it breaks

The call is small. Everything around it is the system.

Retry without idempotency multiplies the artifact

Every layer retries: the gateway, the queue, the customer. Unless the key belongs to the request rather than the attempt, a transient failure becomes two files, two emails and two charges.

Delivery can fail after rendering succeeded

The bucket throttles, the webhook endpoint is down, the customer's firewall blocks you. A design where the artifact exists only inside the delivery attempt loses work that was already paid for.

The webhook receiver must authenticate you

An unsigned callback is an open endpoint that anyone can post to. Signing, timestamp tolerance and replay rejection are yours to build, and they are easy to build subtly wrong.

Concurrency limits are memory, not CPU

Browser jobs do not degrade gracefully. Past a certain number of parallel contexts the worker dies rather than slows, taking healthy jobs with it — so backpressure has to be explicit.

Keep the DIY version when

  • The batch is small enough to run sequentially inside a window you control, and a failed run can be re-run wholesale.
  • You already operate a queue with idempotency and dead-lettering for other work, and reports are one more producer.
The recipe

deliver a generated PDF report without holding the request open

Set `async=true` and the call returns 202 immediately; the artifact goes to your bucket, your webhook, or both. Delivery is signed and retried, the ledger charges once per successful capture, and redelivery is harmless. The runner below shows the synchronous shape of the same request — swap `async` on when the report outgrows the caller's patience.

Capture settings
Enter the source, choose your options, then run the capture.
Result
Your capture will appear here and stay in view.
waiting

No result yet

Complete the settings and run the tool. Images, PDFs, text, and video all preview in this panel.

Send this exact request
cURL, TypeScript and Python are generated from the same configuration as the demo.
curl --fail-with-body "https://api.pagecapture.dev/v1/take" \
  -H "X-Access-Key: $PAGECAPTURE_KEY" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://example.com","format":"pdf","pdf_paper_format":"a4","pdf_print_background":true,"external_identifier":"monthly-report-2026-08"}' \
  -o capture.pdf
What you get, and what it costs

Output, limits, cost, failures

Output

Synchronously, PDF bytes. With `async=true`, a 202 with a request id, then the file in your bucket and a signed webhook when it is there. `external_identifier` travels with the job so your side can correlate without keeping a map.

Cost

One successful, non-cached capture costs one credit. Failures, including platform failures, cost zero; cache hits cost zero.

1 creditper successful non-cached capture

Limits

  • `external_identifier` is metadata: it is echoed back and is deliberately not part of the cache key, so it cannot change which artifact you get.
  • An async request with `response_type=by_format` must have somewhere to deliver — `webhook_url`, `store=true`, or ask for JSON instead.
  • Storage credentials are validated before the render, so a misconfigured bucket fails as validation rather than as a lost artifact.
  • The ledger's unique constraint is what makes webhook redelivery harmless — a repeated delivery cannot produce a second charge.

Common errors

`store=true` without a storage config id or the three inline credentials.

HTTP 400 · terminal

The bucket throttled or timed out; retried internally, never charged.

HTTP 500 · retryable

request_abortednever billed

The caller or a proxy closed a synchronous connection — the signal to move this workload to async.

HTTP 500 · terminal

The month-end batch exceeded the plan's request starts per minute.

HTTP 400 · retryable

Try it against your own page

The runner above carries this exact configuration into the playground — no retyping, no starting over.

Return to the configured runner
Tested and reviewed

Written by PageCapture Engineering. Reviewed by PageCapture API and billing maintainers. Verified 2026-08-12 by running the published code against a fixture.

The published snippets execute against a fixture in CI and the artifact is checked to be a real PDF; the async, storage and billing behaviour described here is covered by the billing invariant suite rather than by this page.

engine-crunknown-pwunknown-f2026-07-1-b2026-07-1StatusAsync delivery and webhooks referenceEditorial method

Sources and verification basis

Sources support the browser and API behaviors named above. PageCapture-specific limits and billing are taken from the public contract; external sources are used for the underlying browser behavior.

  1. Defines 202 acceptance, signed callbacks, retry schedule and deduplication.

  2. Defines bucket delivery, credential handling and which storage failures are retried.

  3. LimitsPageCapture

    Provides the request-start and timeout ceilings the batch has to respect.