How to Set Up Video Rendering Webhooks (Production Guide)
Video rendering is asynchronous — polling wastes 90%+ of requests. This guide covers HMAC SHA-256 signature verification, retry semantics, idempotency, and the raw-body pitfall that breaks most webhook handlers.
Mark D.
Founder

A video rendering API is asynchronous by nature. You POST a render request, the API queues it, and your job sits there for 30 seconds to 5 minutes before completing. The naive way to monitor that — polling a status endpoint every few seconds — wastes 90%+ of requests on identical "still rendering" responses, eats your API rate limits, and adds wall-clock latency that the user feels.
Webhooks are the correct primitive. The provider POSTs your endpoint once when the render finishes. One request in, zero waste. Stripe explicitly recommends webhooks for "asynchronous events that aren't initiated by an API call" (Stripe Docs, 2026) — payments, payouts, refunds. Video renders fit the same shape.
This guide covers what actually matters for production: signature verification done right, retry semantics, idempotency, and the raw-body pitfall that quietly breaks most first-pass implementations.
Key Takeaways
- Polling a render endpoint every 5 seconds for a 2-minute render fires 24 status requests, 23 of which are wasted — webhooks collapse that to one
- HMAC SHA-256 with constant-time comparison is the industry-standard signing pattern (Stripe, GitHub, Svix all use it)
- Sign the raw request body, not the re-serialized JSON — this is the most common implementation bug
- Webhook providers retry on non-2xx responses for 24 hours to 3 days (Stripe, 2026) — handlers must be idempotent
- Acknowledge with 200 in under 5 seconds, then enqueue the heavy work — never do S3 uploads or emails inside the handler
Why Webhooks Beat Polling for Video Renders
The math is unforgiving. For a single render:
| Approach | Requests | Wasted requests | Latency |
|---|---|---|---|
| Polling every 5s, 2-min render | 24 | 23 (96%) | Up to 5s after completion |
| Polling every 30s, 2-min render | 4 | 3 (75%) | Up to 30s after completion |
| Webhook | 1 (inbound) | 0 | ~100ms |
Scale that to 1,000 concurrent renders, and polling becomes a per-second 24,000-request fanout against your API quota. Webhooks scale linearly with completions, not poll frequency.
Industry adoption is near-universal. Every major SaaS platform — Stripe, Twilio, GitHub, Slack, Shopify — exposes webhooks as the primary mechanism for asynchronous state changes (Hookdeck, 2026). If your video API supports them, use them.
For the broader async-render context, see our guide to generating 1,000+ personalized videos with API automation — webhooks are step 4 of that pipeline.
What Does a Renderly Webhook Look Like?
Renderly fires two event types: render.completed and render.failed. Both POST to your registered URL with a JSON body and an x-renderly-signature header.
Successful render payload:
{
"event": "render.completed",
"eventId": "evt_01HXY8Z7K9N2M3R4S5T6U7V8W9",
"timestamp": "2026-05-28T14:23:45.123Z",
"jobId": "job_xyz123",
"status": "completed",
"outputUrl": "https://renders.renderly.video/job_xyz123.mp4",
"duration": 60,
"creditsUsed": 1
}Failed render payload:
{
"event": "render.failed",
"eventId": "evt_01HXY8Z7K9N2M3R4S5T6U7V8WA",
"timestamp": "2026-05-28T14:23:50.456Z",
"jobId": "job_xyz124",
"status": "failed",
"error": "Invalid template ID: product-demo-v99"
}Headers:
x-renderly-signature: sha256=<hex>— HMAC of the raw bodyx-renderly-event: render.completed— convenience header matching the bodyeventfieldx-renderly-delivery-id: <uuid>— unique delivery ID (changes on retries;eventIdis stable)
The secret is provisioned per webhook endpoint in the format whsec_<48-char-hex> — the same prefix convention Stripe and Svix use.
How to Verify a Signature (The Right Way)
This is where most handlers break. The pattern is straightforward — until you hit the raw-body pitfall.
Node.js / Express
import crypto from 'crypto';
import express from 'express';
const app = express();
// CRITICAL: use express.raw, not express.json
// The raw body bytes must be available for signature verification
app.post('/webhooks/renderly',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-renderly-signature'];
const secret = process.env.RENDERLY_WEBHOOK_SECRET;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body) // raw Buffer
.digest('hex');
// Constant-time comparison prevents timing attacks
const valid = signature && crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
if (!valid) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(req.body.toString());
handleEvent(payload);
res.json({ received: true });
}
);Next.js App Router
Next.js Route Handlers preserve the raw body via req.text():
// app/api/webhooks/renderly/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export const runtime = 'nodejs';
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('x-renderly-signature');
const secret = process.env.RENDERLY_WEBHOOK_SECRET!;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
if (!signature || !crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const payload = JSON.parse(rawBody);
await handleEvent(payload);
return NextResponse.json({ received: true });
}Python / FastAPI
import hmac
import hashlib
import json
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/webhooks/renderly")
async def renderly_webhook(request: Request):
raw_body = await request.body() # bytes, not parsed
signature = request.headers.get("x-renderly-signature", "")
secret = os.environ["RENDERLY_WEBHOOK_SECRET"]
expected = "sha256=" + hmac.new(
secret.encode(),
raw_body,
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(raw_body)
await handle_event(payload)
return {"received": True}The Pitfall Nobody Avoids First Try
The most common bug:
// ❌ WRONG — signs re-serialized JSON
app.post('/webhook', express.json(), (req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(JSON.stringify(req.body)) // BUG
.digest('hex');
});JSON.stringify(req.body) does not produce the same bytes the sender signed. Key ordering, whitespace, and Unicode escaping differ across implementations. The bytes diverge → signature mismatch → every webhook fails with a 401.
Always sign the raw request body. Express needs express.raw(), Next.js needs req.text() before req.json(), FastAPI needs await request.body(). This is the single most important rule for webhook handlers.
OWASP documents the broader category — cryptographic comparisons must be constant-time, raw bytes must be preserved (OWASP Cheat Sheet, 2026).
What Retry Behavior Should You Expect?
Webhook providers retry failed deliveries with exponential backoff. Patterns from major providers:
| Provider | Retry window | Pattern |
|---|---|---|
| Stripe | Up to 3 days | Exponential backoff, frequent early retries (Stripe, 2026) |
| GitHub | ~24 hours | 8 attempts, exponential backoff (GitHub Docs, 2026) |
| Svix | ~24 hours | 5 attempts: 5s, 5min, 30min, 2hr, 5hr (Svix, 2026) |
| Renderly | ~24 hours | 5 attempts: 30s, 2min, 10min, 1hr, 6hr |
What this means for your handler:
- Return a 2xx response within 5 seconds. Anything else triggers retries.
- A 5xx response signals "try again" — fine if you're temporarily down.
- A 4xx response signals "permanent failure" — providers stop retrying. Use 401 for bad signatures, 410 for endpoints you've deprecated.
- After the retry window expires, undelivered events are dropped. Plan accordingly.
Why Your Handler Must Be Idempotent
At-least-once delivery is the default. Network partitions, ack timeouts, and explicit retries mean your endpoint will see the same event 2+ times. Idempotent handlers tolerate duplicates without side effects.
The standard pattern: dedupe on a stable event ID before doing any work.
async function handleEvent(payload) {
const { eventId, event, jobId, outputUrl } = payload;
// Atomic insert — if eventId already exists, do nothing
const result = await db.query(`
INSERT INTO processed_webhook_events (event_id, received_at)
VALUES ($1, NOW())
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id
`, [eventId]);
if (result.rows.length === 0) {
// Already processed — return without doing work
return;
}
// Process exactly once
if (event === 'render.completed') {
await persistOutput(jobId, outputUrl);
await notifyDownstream(jobId, outputUrl);
} else if (event === 'render.failed') {
await flagForRetry(jobId, payload.error);
}
}Stripe documents this pattern as required for production webhook receivers (Stripe Best Practices, 2026). Without it, retries cause duplicate S3 uploads, double-charged customers, duplicate email notifications, and broken user experiences.
Store processed event IDs with a TTL equal to the longest retry window — 24 hours minimum, 7 days is safe.
How Should You Handle Heavy Work?
The webhook handler is not the place to do work. It's the place to acknowledge work and hand off.
async function handleEvent(payload) {
// 1. Dedupe (above)
// 2. Enqueue
await queue.add('process-render', payload);
// Handler returns immediately, queue worker processes async
}Why: providers expect a response within 5–10 seconds. A handler that uploads to S3, regenerates thumbnails, and sends emails synchronously can easily take 30+ seconds — triggering provider timeouts, unnecessary retries, and cascading failures.
The right pattern: handler returns 200 in under 1 second, queue worker (BullMQ, SQS, Inngest, Vercel Queues) does the actual work.
How Do You Test Webhooks Locally?
Webhook delivery requires a public HTTPS URL — your local http://localhost:3000 won't receive POSTs from a production API. Three options:
Option A — ngrok. ngrok http 3000 gives you a public URL like https://abc123.ngrok-free.app. Free tier rotates URLs; paid plans pin domains. Best for quick testing.
Option B — Cloudflare Tunnel. cloudflared tunnel --url http://localhost:3000 creates a persistent tunnel for free, with optional custom domains. Good for ongoing development against a real Renderly account.
Option C — Stripe CLI pattern. If your video API ships a CLI with webhook forward capability, that's the cleanest. Pipes provider events straight to your local handler with valid signatures.
Once tunneled, register your tunnel URL in the Renderly dashboard, queue a test render, and watch the events flow.
Common Pitfalls
Security
- Signing the parsed body (re-serialized JSON) instead of raw bytes — covered above, this is the #1 bug.
- No signature verification at all — your webhook URL becomes a public endpoint anyone can POST to. Attackers send fake
render.completedevents with maliciousoutputUrlvalues. - String equality on signatures — vulnerable to timing attacks. Always use
crypto.timingSafeEqual(Node) orhmac.compare_digest(Python). - No timestamp check — without timestamp validation, replay attacks succeed indefinitely. Reject any payload where
|now - timestamp| > 300 seconds.
Reliability
- Synchronous heavy work in the handler — see above. Acknowledge, then enqueue.
- Returning non-2xx for "already processed" — triggers needless retries. Return 200 for idempotent skips.
- No retry budget — if your downstream queue or database is briefly down, returning 5xx tells the provider to retry. Good. Letting it crash and returning nothing causes the same outcome via timeout but wastes 5 seconds per delivery.
Operations
- Logging full payloads —
outputUrlvalues are often presigned S3 URLs that expire in minutes/hours. Logged URLs can leak access to rendered videos. Redact or hash. - No monitoring — track delivery success rate, retry count per event, and dead-letter rate. A spike means something is wrong upstream.
- Skipping the 410 Gone code — when you deprecate an endpoint, return 410 Gone, not 404. Providers will stop retrying immediately on 410.
Frequently Asked Questions
Why use webhooks instead of polling for video renders? Polling wastes ~90% of requests on "still rendering" responses. Webhooks collapse N status checks into one inbound POST when the render completes. Stripe recommends webhooks for asynchronous events that aren't initiated by API calls (Stripe, 2026).
How do I verify a webhook signature in Node.js?
Use HMAC SHA-256 with the raw request body and constant-time comparison via crypto.timingSafeEqual. Re-serializing the parsed JSON before signing is the most common bug.
What retry behavior should I expect from webhook providers? Most providers retry for 24 hours to 3 days with exponential backoff. Stripe retries up to 3 days, GitHub ~24 hours over 8 attempts. Return 2xx within 5 seconds to avoid unnecessary retries.
Why does my webhook handler need to be idempotent?
At-least-once delivery means the same event arrives multiple times. Idempotent handlers dedupe on eventId before doing work — preventing duplicate uploads, emails, and database writes.
How do I test webhooks locally without deploying?
Use ngrok, Cloudflare Tunnel, or your provider's CLI to expose localhost:3000 as a public HTTPS URL. Register that URL as your webhook endpoint and trigger test events.
Should webhook handlers do work synchronously? No. Return 200 in under 1 second and enqueue heavy work to a background queue (BullMQ, SQS, Inngest). Synchronous handlers cause provider timeouts and cascading retries.
The webhook handler is the production seam between your application and an async video API. Get the signature verification right, make it idempotent, and don't do work inside the handler — those three rules cover 95% of what goes wrong.
For the full pipeline that uses these webhooks — from data source to rendered video delivery — see our n8n video automation guide and the Complete Guide to Automating Video Creation in 2026.
Try Renderly's video API — REST endpoints, signed webhooks out of the box, 4K renders on every plan, credits that never expire.
Related Articles

How to Generate 1,000+ Personalized Videos with API Automation (2026 Update)
Personalized videos convert at 3x the rate of generic ones (Tavus, 2025). This developer guide shows how to build a bulk video generation pipeline using REST APIs — 1,000 videos in under 20 minutes at $0.10–0.50 each.
May 18, 2026

The Complete Guide to Automating Video Creation in 2026
Automate video creation in 2026 with templates, AI, and APIs. Production costs dropped from $4,500 to $400 per minute - here's the complete playbook.
April 6, 2026

How to Build Automated Video Workflows with n8n (Step-by-Step Guide)
n8n hit 183k+ GitHub stars and 230k+ active users in 2026 — the fastest-growing workflow automation platform. This guide walks through building a video automation workflow with n8n + Renderly's API in under 45 minutes.
May 25, 2026

How to Create Automated Videos with Make.com (Step-by-Step Guide)
Automate video creation with Make.com and Renderly's API. Make.com handles 4B+ scenario runs yearly - here's how to build your first video workflow in 30 minutes.
April 9, 2026

4 Best Video APIs for Developers in 2026 (Compared)
An honest comparison of the top programmatic video generation APIs for developers. We break down Renderly, Shotstack, Creatomate, and JSON2Video on pricing, features, DX, and scalability.
February 9, 2026