Wiring a Screenshot API to Webhooks: Patterns That Actually Scale
Learn how to build a reliable screenshot API webhook integration for async captures, OG images, and monitoring — with code, retries, and security.
Synchronous screenshot requests work fine until they don't. A heavy single-page app might take 8 seconds to render. A PDF of a 40-page report might take 30. Holding an HTTP connection open that long is fragile, expensive, and often hits gateway timeouts. The fix is a screenshot API webhook integration: fire a request, get an acknowledgement, and receive the finished asset via a callback URL when it's ready.
This post walks through the patterns that hold up in production — request shape, signature verification, retry logic, and how to handle the asset once it lands.
Why Async Beats Blocking for Screenshots
A few concrete reasons developers move from inline responses to webhooks:
- Long-running captures: PDFs of dashboards, multi-page exports, or sites with lazy-loaded content can exceed serverless function limits (10s on Vercel Hobby, 30s on most Lambda defaults).
- Batch jobs: Generating 500 OG images on a deploy shouldn't block your CI pipeline.
- User-facing latency: A user clicks "Export to PDF" — you want to return a job ID immediately and notify them when it's done.
- Cost predictability: Async workers scale independently from your API tier.
The Anatomy of a Webhook-Driven Capture
A typical flow looks like this:
- Your backend POSTs a capture job to the screenshot API, including a
webhook_urlfield. - The API responds with a
job_id(usually within 100ms). - The capture runs in the provider's worker pool.
- The provider POSTs the result — either a signed URL to the asset, or the binary payload itself — to your webhook endpoint.
- Your endpoint verifies the signature, persists the asset, and updates the job state.
A Realistic Request Payload
Using PxShot as an example, an async capture request looks like:
POST https://api.pxshot.dev/v1/capture
Authorization: Bearer sk_live_xxx
Content-Type: application/json
{
"url": "https://example.com/report/482",
"format": "pdf",
"full_page": true,
"wait_until": "networkidle",
"webhook_url": "https://api.yourapp.com/hooks/pxshot",
"metadata": {
"user_id": "u_9123",
"report_id": "r_482"
}
}The metadata field is the unsung hero here — whatever you put in is echoed back on the webhook, so you don't need to maintain a separate job lookup table just to correlate results.
Building the Receiver
Here's a minimal but production-shaped Express handler:
import express from "express";
import crypto from "crypto";
const app = express();
app.use(express.raw({ type: "application/json" }));
app.post("/hooks/pxshot", async (req, res) => {
const signature = req.header("X-PxShot-Signature");
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send("invalid signature");
}
const payload = JSON.parse(req.body.toString());
// Acknowledge fast, process async
res.status(200).send("ok");
await enqueueProcessing(payload);
});Three things to notice:
- Raw body parsing: Signature verification must happen against the exact bytes sent, not a re-serialised JSON object.
- Timing-safe comparison:
===on HMAC strings leaks timing information. UsetimingSafeEqual. - Acknowledge before processing: Return 200 immediately, then push to a queue. If your downstream work fails, retry from your own queue — don't make the provider retry.
What the Callback Payload Contains
A typical success callback:
{
"job_id": "jb_01HXYZ...",
"status": "completed",
"asset_url": "https://cdn.pxshot.dev/captures/01HXYZ.pdf",
"expires_at": "2025-01-20T14:00:00Z",
"duration_ms": 4820,
"metadata": { "user_id": "u_9123", "report_id": "r_482" }
}The asset_url is usually short-lived (24–72 hours). If you need permanent storage, download the asset to your own S3/R2 bucket as part of the processing step.
Handling Failures Without Losing Jobs
Webhooks fail. Networks blip, your servers redeploy, payloads get malformed. Plan for it.
Idempotency
Providers retry. You will receive the same job_id twice. Store processed job IDs in Redis with a 7-day TTL, or use a unique constraint on the job ID column in your database. Check before processing:
const seen = await redis.set(`pxshot:${payload.job_id}`, "1", "NX", "EX", 604800);
if (!seen) return; // already handledRetry Behaviour
Most screenshot APIs will retry failed deliveries with exponential backoff — typically 3–5 attempts over 24 hours. Your endpoint should:
- Return 2xx only when you have safely persisted the payload.
- Return 5xx for transient failures so the provider retries.
- Return 4xx only for truly malformed requests you'll never accept.
Dead-Letter Strategy
If all retries fail, the job is effectively lost. Mitigate this with:
- A nightly reconciliation job that queries the screenshot API for jobs marked "pending" in your DB older than 1 hour.
- A fallback polling endpoint, e.g.
GET /v1/capture/{job_id}, used when webhooks don't arrive within an expected window.
Practical Use Cases
Dynamic OG Image Generation
When a user publishes a post, enqueue a capture of your OG template URL. The webhook stores the resulting PNG in your CDN and updates the post's og_image_url. No blocking on publish.
Visual Regression Monitoring
Cron-trigger captures every 15 minutes for critical pages. The webhook diffs the new screenshot against the baseline using something like pixelmatch, and pings Slack if the difference exceeds 2%.
Async PDF Exports
User clicks "Download Report". You enqueue a PDF capture, return a job ID, and show a progress UI. The webhook delivers the PDF, your backend pushes a notification via WebSocket, and the user gets a download link without ever seeing a spinner stall.
Security Checklist
- Verify every signature — never trust a webhook payload by source IP alone.
- Use HTTPS endpoints only; rotate webhook secrets quarterly.
- Validate the
metadataecho — if a user ID comes back that doesn't match what you sent, treat it as suspect. - Rate-limit your receiver to prevent a misconfigured provider (or attacker) from hammering it.
- Log raw payloads for 7 days so you can replay failed deliveries.
Local Development
Testing webhooks locally is the friction point everyone hits. Use one of:
- ngrok or Cloudflare Tunnel to expose
localhost:3000to the public internet. - webhook.site to inspect payload shape before writing code.
- A staging environment with a dedicated webhook URL pointing at a preview deployment.
PxShot lets you send test webhooks from the dashboard with sample payloads — useful for wiring up the receiver before you've burned a single real capture credit.
If you want to try this end-to-end, PxShot's free tier includes async captures with webhook delivery and is enough to prototype OG image pipelines or PDF exports without a card. Grab a key at pxshot.dev and you can have a working webhook integration running in under 20 minutes.