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.
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.
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.
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.
// 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.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.
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.
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.
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.
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.
One successful, non-cached capture costs one credit. Failures, including platform failures, cost zero; cache hits cost zero.
`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
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
The runner above carries this exact configuration into the playground — no retyping, no starting over.
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.
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.
Defines 202 acceptance, signed callbacks, retry schedule and deduplication.
Defines bucket delivery, credential handling and which storage failures are retried.
Provides the request-start and timeout ceilings the batch has to respect.
The general version of this problem, beyond reports.
When each customer's report needs that customer's session.
The delivery contract: 202, signature, retries and deduplication.
Deliver the report straight into your own bucket.