Webhooks
Webhooks let ViralSync push job status updates to your server instead of requiring you to poll. This is the recommended pattern for production integrations.
How it works
- Include a
callbackUrlin your render job request - ViralSync sends
POSTrequests to that URL as the job progresses - Your server responds with
200 OKimmediately (process asynchronously) - When the job completes, the final event contains
outputUrl
Setting a webhook URL
Add callbackUrl to any render job request:
{
"kind": "movie",
"projectId": "proj_abc123",
"callbackUrl": "https://your-server.com/webhooks/viralsync",
"movie": { ... }
}
The URL must be publicly reachable. For local development, use a tunnel (see Testing locally).
Webhook events
progress — job is running
Sent periodically as the job processes:
{
"jobId": "job_a1b2c3d4e5f6",
"status": "running",
"phase": "rendering",
"progress": 0.46,
"children": {
"total": 3,
"succeeded": 1,
"running": 1,
"queued": 1,
"failed": 0
},
"outputUrl": null,
"timestamp": "2026-04-02T10:00:08.000Z"
}
progressis a float from0.0to1.0(multiply by 100 for percentage)childrenis only present for parent render jobs- Frequency: approximately every 5–10 seconds during active rendering
done — job completed successfully
{
"jobId": "job_a1b2c3d4e5f6",
"status": "done",
"progress": 1.0,
"outputUrl": "https://r2.viralsync.io/outputs/job_a1b2c3d4e5f6/output.mp4",
"mediaData": {
"duration": 10.0,
"width": 1920,
"height": 1080,
"fileSize": 8432156
},
"timestamp": "2026-04-02T10:00:52.000Z"
}
failed — job ended with an error
{
"jobId": "job_a1b2c3d4e5f6",
"status": "failed",
"progress": 0.33,
"error": "SCENE_RENDER_FAILED: FFmpeg exited with code 1 on scene_2",
"outputUrl": null,
"timestamp": "2026-04-02T10:00:31.000Z"
}
cancelled — job was cancelled
{
"jobId": "job_a1b2c3d4e5f6",
"status": "cancelled",
"progress": 0.12,
"outputUrl": null,
"timestamp": "2026-04-02T10:00:15.000Z"
}
Signature verification
Every webhook request includes an x-render-signature header — an HMAC-SHA256 signature computed from your webhook secret and the raw request body. Verify this in your handler to ensure the request came from ViralSync.
Your webhook secret is set in Dashboard → Settings → Webhooks → Secret.
Node.js (Express)
import express from 'express';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.VIRALSYNC_WEBHOOK_SECRET;
const app = express();
// IMPORTANT: Use raw body for signature verification
app.use('/webhooks/viralsync', express.raw({ type: 'application/json' }));
app.post('/webhooks/viralsync', (req, res) => {
const signature = req.headers['x-render-signature'];
const body = req.body; // Buffer
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) {
return res.status(401).send('Invalid signature');
}
// Respond 200 immediately — process asynchronously
res.sendStatus(200);
const event = JSON.parse(body.toString());
processWebhookEvent(event).catch(console.error);
});
async function processWebhookEvent(event) {
const { jobId, status, outputUrl, error } = event;
if (status === 'done') {
console.log(`Job ${jobId} complete. Video: ${outputUrl}`);
// Save outputUrl, notify user, download video, etc.
} else if (status === 'failed') {
console.error(`Job ${jobId} failed: ${error}`);
// Alert, retry, etc.
}
}
Python (Flask)
import hmac
import hashlib
import json
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['VIRALSYNC_WEBHOOK_SECRET']
@app.route('/webhooks/viralsync', methods=['POST'])
def viralsync_webhook():
signature = request.headers.get('x-render-signature', '')
body = request.get_data() # raw bytes
expected = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({'error': 'Invalid signature'}), 401
# Respond 200 immediately
event = json.loads(body)
process_event(event) # call async task queue in production
return jsonify({'received': True}), 200
def process_event(event):
job_id = event['jobId']
status = event['status']
if status == 'done':
output_url = event['outputUrl']
print(f'Job {job_id} complete: {output_url}')
elif status == 'failed':
print(f'Job {job_id} failed: {event.get("error")}')
Retry policy
If your endpoint is unreachable or returns a non-2xx response, ViralSync retries delivery with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 5 seconds |
| 2nd retry | 30 seconds |
| 3rd retry | 5 minutes |
| 4th retry | 30 minutes |
| 5th retry | 2 hours |
After 5 failed attempts, delivery is abandoned. You can still retrieve the final job status via GET /v1/jobs/{jobId}.
Best practices
Respond 200 immediately. Do not perform slow operations (database writes, external API calls) before responding. Return 200 OK and process the event asynchronously (queue the payload, handle it in a background worker).
Make handlers idempotent. The same event may be delivered more than once (e.g. if your server timed out on the first delivery). Use jobId + status as a deduplication key:
const alreadyProcessed = await db.events.exists({ jobId, status });
if (alreadyProcessed) return; // skip duplicate
Verify the signature. Always verify x-render-signature before processing. This prevents spoofed webhook events.
Don't trust progress ordering. Progress events may arrive out of order under high load. Only act on terminal events (done, failed, cancelled).
Testing locally
For local development, use a tunnel to expose your localhost to the internet:
ngrok:
ngrok http 3000
# → https://abc123.ngrok.io
Use https://abc123.ngrok.io/webhooks/viralsync as your callbackUrl.
Cloudflare Tunnel (free, no rate limits):
cloudflared tunnel --url http://localhost:3000
VS Code forward port — in VS Code's Ports panel, forward your local port and set visibility to "Public".
Webhooks vs SSE vs polling
| Webhooks | SSE | Polling | |
|---|---|---|---|
| Best for | Production integrations | Real-time UI | Simple / serverless |
| Infrastructure needed | Public HTTPS endpoint | Long-lived connection | Any HTTP client |
| Counts against rate limit | No | No (per connection) | Yes (per poll) |
| Reliability | Retry on failure | Reconnect on drop | You control |
| Real-time | Near-instant | Real-time | 3–5s delay |