How to Capture a Webpage Screenshot from a Webhook Using an API
Build a webhook flow that validates events, captures webpages with Playwright or a screenshot API, and returns or stores the resulting image safely.
A webhook triggers the workflow; a screenshot API or browser worker does the rendering. A reliable implementation receives and authenticates the event, validates the target URL, starts a capture, then returns or stores the image. Use a synchronous response for short captures. If rendering may take longer than your webhook provider allows, acknowledge the event promptly and process it as a background job.
This guide uses Node.js, Express, and Playwright for a self-hosted implementation. It also covers the design choices and security checks that apply when you use a hosted screenshot API instead.
1. Choose the capture flow
Decide what the caller needs before writing the handler: an image in the webhook response, a stored file, or a callback containing a result URL. Webhook providers often impose a response deadline, so do not keep an inbound request open while a slow page renders unless you know the deadline is long enough.
| Flow | Use it when | Trade-off |
|---|---|---|
| Synchronous capture | Pages render quickly and the sender can wait for the response. | Simple request and response, but the webhook may time out. |
| Queued job | Rendering duration varies, pages are complex, or volume is bursty. | Requires a queue and a way to retrieve results, but lets the handler acknowledge quickly. |
| Hosted API callback | The screenshot provider documents callback delivery and you want it to operate the browser. | Callback behavior and supported options vary by provider. |
| Hosted API polling | The provider returns a job identifier and documents a status endpoint. | Your worker must poll with sensible intervals and stop at a deadline. |
These are separate components: the inbound webhook provider sends an event, and the screenshot service or your own worker captures the page. Callback and batch-job behavior are provider-specific, not universal API features.
2. Self-hosted Node.js example with Playwright
The following small service accepts a JSON event with a URL, verifies an HMAC signature for this example’s webhook format, restricts destination hosts, captures a PNG, and returns the image. The signature header and signing format here are illustrative; replace them with the exact method documented by your webhook provider. The allowlist is intentional: an unrestricted screenshot endpoint can be abused to make your server request internal services.
Install dependencies
npm init -y
npm install express playwright
npx playwright install chromium
Save this as server.mjs. Configure WEBHOOK_SECRET and ALLOWED_HOSTS in the environment. For example, ALLOWED_HOSTS=example.com,www.example.com permits only those exact hostnames and their HTTPS pages.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { chromium } from 'playwright';
const app = express();
const port = Number(process.env.PORT || 3000);
const secret = process.env.WEBHOOK_SECRET;
const allowedHosts = new Set(
(process.env.ALLOWED_HOSTS || '').split(',').map((host) => host.trim().toLowerCase()).filter(Boolean)
);
if (!secret) throw new Error('Set WEBHOOK_SECRET');
if (allowedHosts.size === 0) throw new Error('Set ALLOWED_HOSTS to an explicit hostname allowlist');
// Keep the raw bytes so the signature covers exactly what the sender signed.
app.use(express.raw({ type: 'application/json', limit: '32kb' }));
function verifySignature(rawBody, header) {
if (typeof header !== 'string' || !header.startsWith('sha256=')) return false;
const suppliedHex = header.slice('sha256='.length);
if (!/^[0-9a-f]{64}$/i.test(suppliedHex)) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest();
const supplied = Buffer.from(suppliedHex, 'hex');
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}
function validateTarget(value) {
let url;
try { url = new URL(value); } catch { throw new Error('url must be an absolute URL'); }
if (url.protocol !== 'https:') throw new Error('Only HTTPS URLs are accepted');
if (url.username || url.password) throw new Error('URLs with embedded credentials are not accepted');
if (!allowedHosts.has(url.hostname.toLowerCase())) throw new Error('Host is not allowed');
return url;
}
app.post('/webhooks/capture', async (req, res) => {
if (!verifySignature(req.body, req.header('x-webhook-signature'))) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
let event;
try { event = JSON.parse(req.body.toString('utf8')); }
catch { return res.status(400).json({ error: 'Body must be valid JSON' }); }
if (event.type !== 'capture.requested' || typeof event.url !== 'string') {
return res.status(400).json({ error: 'Expected capture.requested with a URL' });
}
let target;
try { target = validateTarget(event.url); }
catch (error) { return res.status(400).json({ error: error.message }); }
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultNavigationTimeout(30000);
await page.goto(target.href, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Prefer a page-specific ready selector when one is known.
if (event.readySelector) {
await page.locator(event.readySelector).waitFor({ state: 'visible', timeout: 10000 });
}
const png = await page.screenshot({ fullPage: event.fullPage === true, type: 'png' });
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'no-store');
return res.status(200).send(png);
} catch (error) {
console.error('Capture failed:', error.message);
return res.status(502).json({ error: 'Capture failed' });
} finally {
if (browser) await browser.close();
}
});
app.listen(port, () => console.log(`Listening on ${port}`));
Start it with WEBHOOK_SECRET='replace-with-provider-secret' ALLOWED_HOSTS='example.com' node server.mjs. Send a test event using the same HMAC convention as the example:
node --input-type=module -e '
import { createHmac } from "node:crypto";
const body = JSON.stringify({type:"capture.requested", url:"https://example.com", fullPage:true});
const signature = "sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET).update(body).digest("hex");
const response = await fetch("http://localhost:3000/webhooks/capture", {
method: "POST",
headers: {"content-type":"application/json", "x-webhook-signature":signature},
body
});
console.log(response.status, response.headers.get("content-type"));
if (response.ok) await Bun.write("capture.png", response);
else console.log(await response.text());
'
The sample uses Bun’s file helper only for the local test command. Alternatively, save the HTTP response with curl or use Node’s filesystem API. Production code should also implement your sender’s timestamp and replay checks where available, plus request rate limits and a background queue when captures can exceed the webhook response deadline.
3. Validate events and protect the capture worker
- Authenticate before trusting event data. Use the sender’s documented signature or authentication scheme. Verify signatures against the raw request body if required. Apply the provider’s timestamp/replay protections so a captured request cannot be replayed indefinitely.
- Parse a narrow schema. Accept only the event types and fields required for capture. Enforce a small body limit and reject malformed URLs, unsupported output formats, or unexpected options.
- Constrain destinations. Allow only intended schemes and hosts. Reject loopback, private, link-local, and metadata-service addresses if your use case cannot use a strict hostname allowlist. DNS resolution and redirects need attention too: a public hostname can resolve or redirect to a private address. Enforce network egress controls as a second boundary.
- Keep credentials server-side. Do not put screenshot API keys in browser code, query strings exposed to users, or logs. If the target page needs authentication, send only narrowly scoped credentials and avoid logging them.
- Use a job ID for asynchronous work. Persist a minimal job record, acknowledge a valid webhook with the sender’s expected success response, and let a worker capture and store the image. Make retries idempotent so one event does not create duplicate work or charges.
- Log operational metadata. Record the event/job ID, host, duration, result status, and error category. Avoid logging signed payloads, secrets, cookies, or sensitive screenshots.
4. Set page readiness and screenshot options
A page load event does not necessarily mean the visual state you need is ready. Use the narrowest useful readiness condition:
- Specific selector: wait for a known element that indicates the page is ready, such as a report container. This is usually more meaningful than a fixed sleep when the page has an identifiable ready state.
- DOM content loaded: a practical starting point for pages whose main markup appears early. Images, fonts, and client-side data may still be loading.
- Network idle: useful for some applications, but analytics, polling, and long-lived connections can prevent idleness. A quiet network also does not prove the desired content has rendered.
- Short delay: use only when the page has a known animation or delayed visual update with no better readiness signal. Keep it bounded.
Playwright’s page screenshot API supports saving to a path or receiving image bytes, along with full-page capture and screenshot options. Choose a viewport deliberately; a full-page capture can be very tall and consume substantial memory. For a single element, locate it and call the locator’s screenshot method rather than capturing the entire page.
| Need | Implementation choice |
|---|---|
| Visible viewport | Capture without full-page mode and set a fixed viewport size. |
| Entire document | Enable full-page capture; consider very long pages and lazy-loaded content. |
| One component | Capture a locator/element after waiting for it to be visible. |
| Consistent rendering | Set viewport, device scale, color scheme, locale, and timezone as needed; pin browser versions in deployment. |
| Private page | Use a dedicated browser context with required cookies or headers, then close it after capture. |
| Alternative output | Playwright can produce PNG or JPEG screenshots; use a documented provider or conversion step if WebP or PDF is required. |
5. Choose a hosted API or your own browser
A hosted API removes browser installation and maintenance from your application. A self-hosted Playwright worker gives you direct control over browser contexts and network environment, while making you responsible for browser updates, capacity, isolation, and cleanup. Compare documented API behavior rather than assuming screenshot services support the same features.
ScreenshotNeo is a screenshot API and MCP server. Its one-request API can return PNG, JPEG, WebP, or PDF and offers options including full-page and element capture, readiness waits, headers and cookies, output sizing, caching, async jobs, and bulk capture. See the ScreenshotNeo API documentation for request parameters and response behavior.
Other documented provider-specific patterns include ScreenshotAPI.se’s POST capture workflow with a webhook_url, Screenshot API’s batch job ID pattern, and Capture’s signed URL format. Confirm each provider’s current authentication, callback, response mode, limits, and pricing in its own documentation before adopting it; those behaviors are not interchangeable.
6. Return, store, or forward the result
Choose one result contract and keep it stable:
- Binary response: set the correct
Content-Typeand return the bytes. This works only when the original caller can wait for the capture. - Object storage: write the image to a private bucket and return a short-lived authorized link or an internal object key. Set a retention policy that matches the sensitivity of captured pages.
- Callback: after an async job finishes, send a signed result event to the destination defined by the integration. Retry temporary delivery failures with a bounded backoff.
- Polling: expose a job status endpoint that returns queued, running, succeeded, or failed, and a result reference when complete.
Do not assume a hosted image URL is permanent or public. Check provider retention, access controls, and expiration behavior. If a downstream system only accepts a URL, make sure the link remains available for its actual processing window.
7. Performance, reliability, and cost
- Bound work: set navigation and selector timeouts, cap page size and concurrent browser contexts, and reject requests that exceed your allowed capture policy.
- Reuse carefully: browser processes can be reused by a worker to reduce startup overhead, but use isolated contexts per job and close pages/contexts after completion. Do not let cookies or storage leak between tenants.
- Queue bursts: limit worker concurrency to available CPU and memory. Apply backpressure rather than accepting unlimited jobs that will time out later.
- Retry selectively: retry transient network or provider errors with a bounded exponential backoff and jitter. Do not repeatedly retry invalid URLs, authentication failures, or deterministic page errors.
- Make jobs idempotent: use the webhook event ID or a derived idempotency key to prevent duplicate captures when the sender retries delivery.
- Budget output: full-page and high-resolution captures use more memory and produce larger files. Compress or resize only when the receiving system’s requirements allow it.
- Compare total cost: self-hosting includes compute, storage, browser maintenance, and operational time. Hosted pricing may depend on quota and features; check current published terms and count only the captures your workflow actually needs.
No latency or reliability figure is assumed here. Actual time depends on the destination page, readiness condition, browser environment, network, and provider limits.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Webhook returns unauthorized | Signature header, secret, body encoding, or signing algorithm differs from the provider contract. | Follow the sender’s exact signature instructions; verify using the raw bytes and check secret rotation. |
| Webhook sender reports timeout | The handler waited for a slow navigation or browser startup. | Move capture to a queue, acknowledge promptly, and deliver the result asynchronously. |
| Capture returns a blank or incomplete page | Capture ran before client rendering, data loading, fonts, or images completed. | Wait for an application-specific selector or bounded readiness condition; inspect navigation and console failures. |
| Selector wait times out | The selector is wrong, hidden, conditionally rendered, or blocked by a consent dialog. | Confirm the selector in the rendered DOM, wait for the correct state, or handle the page’s consent flow explicitly. |
| Navigation timeout | The destination is slow, unreachable, blocked, or never reaches the chosen lifecycle condition. | Use a bounded timeout, a more appropriate readiness event, and report a failed job rather than holding the webhook open. |
| Unexpected internal destination is reachable | URL validation checked syntax but not redirects, DNS results, or private address ranges. | Use strict host allowlists, validate resolved addresses and redirect targets, and enforce outbound network restrictions. |
| Browser process exits or runs out of memory | Too many concurrent jobs, huge full-page documents, or leaked pages/contexts. | Limit concurrency, cap page dimensions where possible, close resources in finally blocks, and monitor worker memory. |
| Duplicate screenshots appear | The webhook sender retried after not receiving an acknowledgement. | Persist event IDs and make processing idempotent; return the expected acknowledgement promptly. |
| Output cannot be opened downstream | Wrong MIME type, truncated transfer, or downstream expected a URL rather than bytes. | Match the integration’s result contract and set/verify the actual image content type. |
9. Or skip the browser setup
Send the target URL to ScreenshotNeo’s API and receive an image response. Keep the API key on your server and pass it as a request parameter, as shown in the API documentation.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
In your webhook handler, validate the event and destination first, then make the API request from the server. ScreenshotNeo removes cookie banners, popups, and chat widgets before capture; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000.
Create a free ScreenshotNeo account to get started.
10. FAQ
Can a webhook take the screenshot by itself?
No. It notifies your service that work should happen. Your handler must call a screenshot API or schedule a browser worker.
Should the image be included in the webhook response?
Only when the sender can wait for rendering and accepts binary output. Most integrations are easier to operate when the webhook is acknowledged first and the completed image is delivered or retrieved separately.
Does network idle guarantee the page is ready?
No. A page may become visually ready while background requests continue, or appear network-idle before its important content renders. A page-specific selector is often a better signal.
Can I capture authenticated pages?
Yes, if your browser worker or API supports the required cookies or headers. Handle credentials as secrets, scope them narrowly, and isolate each capture context.
Should I use PNG, JPEG, WebP, or PDF?
Use the format required by the downstream system. PNG is suitable when lossless output matters; JPEG or WebP can reduce image size. Use PDF when the deliverable is a document rather than a raster image, and confirm the chosen capture service supports that output.


