Skip to main content

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

  1. Include a callbackUrl in your render job request
  2. ViralSync sends POST requests to that URL as the job progresses
  3. Your server responds with 200 OK immediately (process asynchronously)
  4. 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"
}
  • progress is a float from 0.0 to 1.0 (multiply by 100 for percentage)
  • children is 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:

AttemptDelay
1st retry5 seconds
2nd retry30 seconds
3rd retry5 minutes
4th retry30 minutes
5th retry2 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

WebhooksSSEPolling
Best forProduction integrationsReal-time UISimple / serverless
Infrastructure neededPublic HTTPS endpointLong-lived connectionAny HTTP client
Counts against rate limitNoNo (per connection)Yes (per poll)
ReliabilityRetry on failureReconnect on dropYou control
Real-timeNear-instantReal-time3–5s delay