Async and webhooks

202, signed callbacks, retries and deduplication.

async=true returns immediately with 202 Accepted and a request id:

{ "is_successful": true, "request_id": "req_01...", "status": "accepted" }

Pair it with webhook_url to be told when the file is ready. An async request with response_type=by_format and neither a webhook nor storage is rejected — the result would have nowhere to go.

Verifying a webhook

X-PageCapture-Event-Id: evt_01...
X-PageCapture-Timestamp: 1785520800
X-PageCapture-Signature: v1=<hex>
X-PageCapture-Attempt: 1

The signed content is timestamp + "." + raw_body, with HMAC-SHA256 under your signing secret. Verify the signature against the raw body, before parsing.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, headers: Headers, secret: string) {
  const timestamp = headers.get("x-pagecapture-timestamp")!;
  const received = headers.get("x-pagecapture-signature")!;
  const expected = "v1=" + createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  // Reject anything older than five minutes.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

Retries

Delivery is retried immediately, then after 30 s, 2 min, 10 min, 1 h, 6 h and 24 h, with jitter. Every attempt carries the same X-PageCapture-Event-Id and an incrementing X-PageCapture-Attempt, so your endpoint can deduplicate on the event id.

Any 2xx is success. A 410 Gone stops delivery permanently.

A webhook that never succeeds does not refund the credit: the capture was produced and remains retrievable through GET /v1/requests/{id} for its retention window.