How Do Screenshot Webhooks Work?
Build event-driven screenshot automation with webhooks. Get notified when captures complete and process results asynchronously.
Synchronous screenshot requests block your application while waiting for captures to complete. Webhooks enable async processing—submit captures, continue working, and receive notifications when they're ready.
This guide covers implementing webhook-based screenshot automation. The reference implementations worth studying first: Stripe webhooks (the de-facto standard for HMAC-signed delivery), GitHub webhooks (well-documented retry semantics), and Standard Webhooks (the cross-vendor spec aimed at unifying webhook conventions). For HMAC verification specifics, the OWASP authentication cheatsheet has the constant-time comparison patterns you should use.
Why Webhooks?
Webhooks turn a 10-30 second blocking call into a fire-and-forget async pattern. Submit the capture, get a job ID back in milliseconds, and your server gets pinged at a callback URL when the image is ready. HMAC-signed payloads prevent forged events; idempotent handlers (deduped on capture ID) prevent duplicate work on replay.
Synchronous Problems
// Blocks for 10-30+ seconds
const screenshot = await captureScreenshot(url);
// User waits...
Issues:
- User experience suffers during wait
- Request timeouts on slow pages
- Can't easily batch many captures
- Server resources tied up waiting
Async Advantages
// Returns immediately
await submitCapture(url, { webhookUrl: 'https://myapp.com/webhook' });
// Continue immediately, handle result later
Benefits:
- Immediate API response
- No timeout issues
- Easy batch processing
- Better resource utilization
- Can retry without blocking
Basic Implementation
Submitting Async Captures
async function submitAsyncCapture(url, options = {}) {
const jobId = crypto.randomUUID();
const response = await fetch('https://api.screenshotly.app/screenshot', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
...options,
async: true,
webhookUrl: `https://myapp.com/api/webhooks/screenshot?jobId=${jobId}`,
}),
});
return {
jobId,
status: 'submitted',
submittedAt: new Date(),
};
}
Receiving Webhook
// Express webhook handler
app.post('/api/webhooks/screenshot', async (req, res) => {
const { jobId } = req.query;
const { success, imageUrl, error, metadata } = req.body;
try {
if (success) {
// Download and store the image
const imageData = await fetch(imageUrl).then(r => r.arrayBuffer());
await storage.save(`screenshots/${jobId}.png`, imageData);
// Update job status
await db.jobs.update(jobId, {
status: 'completed',
imageUrl: `/screenshots/${jobId}.png`,
completedAt: new Date(),
});
} else {
await db.jobs.update(jobId, {
status: 'failed',
error,
failedAt: new Date(),
});
}
res.status(200).send('OK');
} catch (err) {
console.error('Webhook processing failed:', err);
res.status(500).send('Processing failed');
}
});
Webhook Security
Signature Verification
Verify webhooks actually come from the API:
import crypto from 'crypto';
function verifyWebhookSignature(req) {
const signature = req.headers['x-screenshotly-signature'];
const timestamp = req.headers['x-screenshotly-timestamp'];
const body = JSON.stringify(req.body);
// Check timestamp to prevent replay attacks
const age = Date.now() - parseInt(timestamp);
if (age > 300000) { // 5 minutes
throw new Error('Webhook timestamp too old');
}
// Verify signature
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${body}`)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
throw new Error('Invalid webhook signature');
}
return true;
}
app.post('/api/webhooks/screenshot', (req, res) => {
try {
verifyWebhookSignature(req);
// Process webhook...
} catch (err) {
console.error('Webhook verification failed:', err);
res.status(401).send('Unauthorized');
}
});
IP Allowlisting
Restrict webhook sources:
const ALLOWED_IPS = [
'203.0.113.0/24', // Example API IP range
];
function isAllowedIP(ip) {
return ALLOWED_IPS.some(range => ipRangeCheck(ip, range));
}
app.post('/api/webhooks/screenshot', (req, res) => {
const clientIP = req.ip || req.connection.remoteAddress;
if (!isAllowedIP(clientIP)) {
console.warn(`Blocked webhook from ${clientIP}`);
return res.status(403).send('Forbidden');
}
// Process webhook...
});
Handling Webhook Events
Event Types
app.post('/api/webhooks/screenshot', async (req, res) => {
const { event, data } = req.body;
switch (event) {
case 'screenshot.completed':
await handleCompleted(data);
break;
case 'screenshot.failed':
await handleFailed(data);
break;
case 'screenshot.progress':
await handleProgress(data);
break;
default:
console.log('Unknown event:', event);
}
res.status(200).send('OK');
});
async function handleCompleted(data) {
const { jobId, imageUrl, metadata } = data;
// Store image
const image = await fetch(imageUrl).then(r => r.buffer());
await storage.upload(`screenshots/${jobId}.png`, image);
// Update database
await db.screenshots.update(jobId, {
status: 'completed',
url: `screenshots/${jobId}.png`,
width: metadata.width,
height: metadata.height,
size: metadata.size,
});
// Notify user
await notifyUser(jobId, 'Screenshot ready!');
}
async function handleFailed(data) {
const { jobId, error, errorCode } = data;
await db.screenshots.update(jobId, {
status: 'failed',
error: error,
errorCode: errorCode,
});
// Maybe retry
if (isRetryableError(errorCode)) {
await retryCapture(jobId);
}
}
Retry and Reliability
Idempotent Processing
Handle duplicate webhooks:
async function processWebhook(jobId, data) {
// Use transaction to prevent duplicates
const processed = await db.transaction(async (trx) => {
const job = await trx.jobs.findOne(jobId);
// Already processed this webhook
if (job.webhookProcessed) {
return false;
}
// Mark as processed
await trx.jobs.update(jobId, {
...data,
webhookProcessed: true,
webhookProcessedAt: new Date(),
});
return true;
});
if (!processed) {
console.log(`Duplicate webhook for ${jobId}, ignoring`);
}
return processed;
}
Dead Letter Queue
Track failed webhook processing:
async function processWebhookWithDLQ(req) {
try {
await processWebhook(req.body);
} catch (error) {
// Store in dead letter queue for investigation
await db.webhookDLQ.insert({
payload: req.body,
error: error.message,
receivedAt: new Date(),
attempts: 1,
});
throw error;
}
}
// Retry dead letters periodically
async function retryDeadLetters() {
const deadLetters = await db.webhookDLQ
.where('attempts')
.lt(3)
.toArray();
for (const dl of deadLetters) {
try {
await processWebhook(dl.payload);
await db.webhookDLQ.delete(dl.id);
} catch (error) {
await db.webhookDLQ.update(dl.id, {
attempts: dl.attempts + 1,
lastError: error.message,
});
}
}
}
Polling Fallback
Not all webhooks arrive. Implement polling as fallback:
async function checkPendingJobs() {
const pending = await db.jobs
.where('status')
.equals('submitted')
.where('submittedAt')
.lt(new Date(Date.now() - 60000)) // Older than 1 minute
.toArray();
for (const job of pending) {
const status = await checkJobStatus(job.apiJobId);
if (status.completed) {
await handleCompleted({
jobId: job.id,
imageUrl: status.imageUrl,
metadata: status.metadata,
});
} else if (status.failed) {
await handleFailed({
jobId: job.id,
error: status.error,
});
}
// Still pending, will check again next run
}
}
// Run every minute
setInterval(checkPendingJobs, 60000);
Scaling Webhooks
Queue-Based Processing
Don't process webhooks inline:
app.post('/api/webhooks/screenshot', async (req, res) => {
// Immediately acknowledge
res.status(200).send('OK');
// Queue for processing
await webhookQueue.add('process', {
payload: req.body,
receivedAt: new Date(),
});
});
// Process asynchronously
webhookQueue.process('process', 10, async (job) => {
await processWebhook(job.data.payload);
});
Worker Scaling
// Horizontally scale webhook workers
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
} else {
// Each worker processes webhooks from shared queue
startWebhookWorker();
}
Frontend Integration
Real-Time Updates
Notify frontend when screenshots complete:
// WebSocket notification
io.on('connection', (socket) => {
socket.on('subscribe', (jobId) => {
socket.join(`job:${jobId}`);
});
});
async function handleCompleted(data) {
// ... save screenshot ...
// Notify connected clients
io.to(`job:${data.jobId}`).emit('screenshot-ready', {
jobId: data.jobId,
imageUrl: data.imageUrl,
});
}
Frontend Component
function ScreenshotCapture() {
const [jobId, setJobId] = useState(null);
const [status, setStatus] = useState('idle');
const [imageUrl, setImageUrl] = useState(null);
useEffect(() => {
if (jobId) {
socket.emit('subscribe', jobId);
socket.on('screenshot-ready', (data) => {
if (data.jobId === jobId) {
setStatus('completed');
setImageUrl(data.imageUrl);
}
});
}
return () => socket.off('screenshot-ready');
}, [jobId]);
async function capture(url) {
setStatus('capturing');
const { jobId } = await submitAsyncCapture(url);
setJobId(jobId);
}
return (
<div>
{status === 'idle' && <CaptureForm onSubmit={capture} />}
{status === 'capturing' && <LoadingSpinner />}
{status === 'completed' && <img src={imageUrl} alt="Screenshot" />}
</div>
);
}
Best Practices
1. Respond Quickly
Always return 200 immediately:
app.post('/webhook', (req, res) => {
res.status(200).send('OK'); // Respond first
processAsync(req.body); // Process after
});
2. Verify Everything
- Signature validation
- Timestamp checking
- IP allowlisting
- Payload validation
3. Be Idempotent
Handle the same webhook multiple times safely.
4. Log Extensively
app.post('/webhook', (req, res) => {
console.log('Webhook received:', {
jobId: req.body.jobId,
event: req.body.event,
timestamp: new Date(),
});
// ...
});
5. Have Fallbacks
Implement polling for missed webhooks.
Conclusion
Webhook-based screenshot automation enables:
- Non-blocking captures - Submit and continue
- Better scaling - Handle many concurrent captures
- Improved UX - No long waits
- Reliable processing - Retries and fallbacks
Implement proper verification, idempotency, and fallbacks for production reliability.
Ready for async screenshot automation?
Get your free API key → - 100 free screenshots to get started.
See also: Batch Processing Guide →
About the Author

Asad Ali
Full-Stack Developer and Founder of ZTabs with 8+ years of experience building scalable web applications and APIs. Specializes in performance optimization, SaaS development, and modern web technologies.
Credentials: Founder & CEO at ZTabs, Full-Stack Developer, Expert in Next.js, React, Node.js, and API optimization
Frequently Asked Questions
When should I use webhooks instead of synchronous capture?
When the capture is slow enough that an HTTP request would time out (large PDFs, scrolling full-page captures on heavy SPAs), or when you're kicking off many captures at once and don't want to hold connections open. For fast single captures, sync response is simpler and lower-latency.
How do I verify a webhook came from the screenshot API and not an attacker?
HMAC signature verification. The webhook includes an X-Signature header computed as HMAC-SHA256 of the raw body using your webhook secret. Compute the same hash server-side and compare in constant time. Reject any request where the signature doesn't match — without this, an attacker can forge capture completion events.
What retry policy should I use on webhook delivery failures?
Exponential backoff for up to 24 hours: 1m, 5m, 15m, 1h, 6h, 24h. After final failure, route to dead-letter queue for manual review. On the receiving end, ensure your webhook handler is idempotent (dedupe on capture ID) so replayed deliveries don't cause duplicate work.
Ready to capture your first screenshot?
Get started with 100 free screenshots. No credit card required.