Screenshot Webhooks: Real-Time Capture Notifications
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.
Why Webhooks?
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
Ready to capture your first screenshot?
Get started with 100 free screenshots. No credit card required.