How to Set Up Video Rendering Webhooks
Polling a render burns your API rate limit. Set up video rendering webhooks with HMAC SHA-256 verification, retries, idempotency and the raw-body fix.
Founder

A video rendering API is asynchronous by nature. You POST a render request, the API queues it, and the job runs for as long as the video takes to render. The naive way to monitor that - polling a status endpoint every few seconds - spends most of its requests on identical "still rendering" responses, eats your API rate limit, and adds latency that the user feels.
Webhooks are the correct primitive. The provider POSTs your endpoint once when the render finishes. Stripe's docs put the case plainly: "Receiving webhook events helps you respond to asynchronous events" (Stripe Docs, 2026). A finished render is that kind of event.
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 every 5 seconds costs 12 requests a minute. Renderly counts each status read against a per-minute limit of 60, 120 or 300 by plan, so polling caps you at 5 to 25 renders in flight
- HMAC SHA-256 over the raw body, compared in constant time, is the standard signing pattern. Check the signature length first: Node's
timingSafeEqualthrows on a length mismatch- Sign the raw request body, not the re-serialized JSON - this is the most common implementation bug
- Retry windows differ widely: Stripe up to three days, Svix about 27 hours, GitHub none. Renderly retries for a few seconds, so keep a reconciliation poll
- Return a 2xx within 10 seconds, then enqueue the heavy work - never do S3 uploads or emails inside the handler
Why Webhooks Beat Polling for Video Renders
Every request to /api/v1/* counts against a per-minute limit: 60 with no subscription, 120 on Creator, 300 on Business (Renderly API reference). Status reads are no exception. Take a render that runs for 2 minutes as a worked example:
| Approach | Requests | Wasted requests | Delay after completion |
|---|---|---|---|
| Polling every 5s, 2-min render | 24 | 23 (96%) | Up to 5s |
| Polling every 30s, 2-min render | 4 | 3 (75%) | Up to 30s |
| Webhook | 0 (one inbound POST) | 0 | Delivery time only |
Scale that to 1,000 renders polled every 5 seconds and you send 12,000 status requests a minute. The Business limit is 300. Polling at that rate lets you watch 25 renders at once on Business, 10 on Creator and 5 with no subscription. The rest of your 429s are self-inflicted. Webhooks scale with completions, not with poll frequency, and they cost you no requests at all.
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 (VALID_EVENTS in the webhooks route). Both POST to your registered URL with a JSON body and an X-Renderly-Signature header. Job details live under a nested data object, not at the top level.
Successful render payload:
{
"event": "render.completed",
"deliveryId": "clx789ghi012jkl345mno678",
"timestamp": "2026-10-22T14:23:45.123Z",
"data": {
"jobId": "clx456def789",
"projectId": "proj_abc123",
"status": "COMPLETED",
"outputUrl": "https://renderly-output.s3.amazonaws.com/.../final.mp4",
"outputSize": 12458960,
"durationInFrames": 1800,
"width": 1920,
"height": 1080,
"fps": 30,
"duration": 45000,
"creditsUsed": 1,
"completedAt": "2026-10-22T14:23:45.123Z"
}
}Failed render payload:
{
"event": "render.failed",
"deliveryId": "clx789ghi012jkl345mno679",
"timestamp": "2026-10-22T14:23:50.456Z",
"data": {
"jobId": "clx456def790",
"status": "FAILED",
"errorMessage": "Invalid template ID: product-demo-v99",
"creditsUsed": 0,
"completedAt": "2026-10-22T14:23:50.456Z"
}
}A few things to note. status is uppercase (COMPLETED / FAILED), matching the render job's own enum. duration is the render time in milliseconds, not the video length - the video length is durationInFrames / fps, here 60 seconds, which is why creditsUsed is 1 (one credit renders one minute of 1080p). Fields that don't apply are omitted rather than sent as null, so read them with optional access. The full contract is in the webhooks reference.
Headers:
X-Renderly-Signature: <hex>- HMAC-SHA256 of the raw body, hex encoded, with no scheme prefix. Don't prependsha256=when comparing.X-Renderly-Timestamp: <unix-seconds>- when the delivery was sent, in epoch seconds. Use it to reject stale deliveries.X-Renderly-Delivery: <id>- stable across retries of the same delivery, and the same value asdeliveryIdin the body. Sent for registered webhooks only; one-offwebhookUrlcallbacks have no delivery record and omit both.
HTTP header names are case-insensitive, so req.headers['x-renderly-signature'] in Node reads the same header.
The secret is generated per webhook endpoint as whsec_ followed by 48 hex characters, the same prefix convention Stripe uses. It is shown once, in the create response or in the dashboard. You can register up to 10 webhooks on Creator and 25 on Business (webhookLimit in the plan config).
One-off webhookUrl callbacks are not authenticated. There is no registered secret behind them, so their signature header proves nothing about the sender. Put an unguessable token in the callback path, or use a registered webhook for anything production-facing. They are also sent once, with no retries, so a single failed request loses the event.
How to Verify a Signature (The Right Way)
This is where most handlers break. The pattern is short, but it has two traps: the raw body, and the length check before the constant-time compare.
Node's docs say timingSafeEqual inputs "must have the same byte length. An error is thrown if a and b have different byte lengths" (Node.js crypto docs, 2026). A real Renderly signature is always 64 hex characters, but a forged request can send anything. Without a length check, a short fake signature throws a RangeError and your endpoint answers 500 instead of 401. The samples below decode both sides as hex and compare lengths first.
Node.js / Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
function verifySignature(rawBody, signatureHeader, secret) {
if (typeof signatureHeader !== 'string') return false;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest();
const received = Buffer.from(signatureHeader, 'hex');
// timingSafeEqual throws on a length mismatch, so check first
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}
function isFresh(timestampHeader) {
const skew = Math.abs(Date.now() / 1000 - Number(timestampHeader));
return Number.isFinite(skew) && skew <= 300;
}
// 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 ok = verifySignature(
req.body, // raw Buffer
req.headers['x-renderly-signature'],
process.env.RENDERLY_WEBHOOK_SECRET
);
if (!ok) {
return res.status(401).json({ error: 'Invalid signature' });
}
if (!isFresh(req.headers['x-renderly-timestamp'])) {
return res.status(400).json({ error: 'Stale delivery' });
}
const payload = JSON.parse(req.body.toString('utf8'));
handleEvent(payload); // enqueue, don't await heavy work
res.status(200).json({ received: true });
}
);Next.js App Router
Next.js Route Handlers give you the raw body via req.text():
// app/api/webhooks/renderly/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node: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 timestamp = Number(req.headers.get('x-renderly-timestamp'));
const secret = process.env.RENDERLY_WEBHOOK_SECRET!;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest();
const received = Buffer.from(signature, 'hex');
if (
received.length !== expected.length ||
!crypto.timingSafeEqual(received, expected)
) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
if (!(Math.abs(Date.now() / 1000 - timestamp) <= 300)) {
return NextResponse.json({ error: 'Stale delivery' }, { status: 400 });
}
const payload = JSON.parse(rawBody);
await handleEvent(payload); // keep this fast: dedupe + enqueue
return NextResponse.json({ received: true });
}Python / FastAPI
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request
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", "")
timestamp = request.headers.get("x-renderly-timestamp", "")
secret = os.environ["RENDERLY_WEBHOOK_SECRET"]
expected = hmac.new(
secret.encode(),
raw_body,
hashlib.sha256
).hexdigest()
# Constant-time comparison
# Compare bytes: compare_digest raises TypeError on a non-ASCII str
if not hmac.compare_digest(signature.encode(), expected.encode()):
raise HTTPException(status_code=401, detail="Invalid signature")
try:
skew = abs(time.time() - int(timestamp))
except ValueError:
skew = float("inf")
if skew > 300:
raise HTTPException(status_code=400, detail="Stale delivery")
payload = json.loads(raw_body)
await handle_event(payload)
return {"received": True}Python's hmac.compare_digest does not raise on a length mismatch, but it does raise TypeError when a str argument holds non-ASCII characters (the docs say the str form is "ASCII only"). A forged header can contain them, so the sample encodes both sides and compares bytes. Its docs say it "uses an approach designed to prevent timing analysis by avoiding content-based short circuiting behaviour" (Python docs, 2026), and different lengths can reveal only the lengths, not the values.
The Pitfall Nobody Avoids First Try
The most common bug:
// โ WRONG - signs re-serialized JSON
app.post('/webhook', express.json(), (req, res) => {
const expected = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(req.body)) // BUG
.digest('hex');
});JSON.stringify(req.body) is not guaranteed to produce the bytes the sender signed. Whitespace and Unicode escaping can differ between serializers. One changed byte gives a different HMAC, and every webhook fails with a 401.
Always sign the raw request body. Express needs express.raw(), Next.js needs req.text() before any JSON parsing, FastAPI needs await request.body(). Stripe's docs say the same about their own signatures: "Any manipulation to the raw body of the request causes the verification to fail" (Stripe Docs, 2026).
What Retry Behavior Should You Expect?
Retry windows differ by orders of magnitude between providers, and one major provider does not retry at all:
| Provider | Retry window | Pattern |
|---|---|---|
| Stripe | Up to 3 days (live mode) | Exponential backoff (Stripe, 2026) |
| Svix | About 27.6 hours | 8 attempts: immediately, then 5s, 5min, 30min, 2h, 5h, 10h, 10h (Svix, 2026) |
| GitHub | None | "GitHub does not automatically redeliver failed deliveries" - redeliver by hand or by script (GitHub Docs, 2026) |
| Renderly | A few seconds | 3 attempts, 1s then 2s apart |
Renderly's retry window is deliberately narrow, and much shorter than Stripe's or Svix's. Three attempts a second or two apart covers a momentary blip, not a deploy or an outage. If your endpoint is down for a minute, those deliveries are lost. Treat webhooks as the fast path and keep a reconciliation job that polls GET /api/v1/renders/:jobId for any render you never heard about. A reconciliation pass every few minutes over the renders you are waiting on costs a handful of requests, not 12 a minute per render. Don't design as though you have Stripe's three days.
What this means for your Renderly handler:
- Return a 2xx within 10 seconds. That's the delivery timeout; anything slower counts as a failure and is retried.
- Renderly retries on any non-2xx response, a timeout or a network error, 4xx included. A 5xx is the right answer when you are briefly unable to take the delivery.
- Use 401 for a bad signature. Renderly retries it too, but a real delivery signed with the right secret will not fail verification, so a 401 means a misconfigured secret or a request that did not come from Renderly.
- Retries apply to registered webhooks only. A one-off
webhookUrlcallback is sent once. - You can pause or reactivate an endpoint from the dashboard (
isActive).
Why Your Handler Must Be Idempotent
Stripe warns that "webhook endpoints might occasionally receive the same event more than once" (Stripe Docs, 2026, same page as above), and the same holds for any sender that retries. A response that arrives after the sender gave up, or a retry after a timeout, delivers the same event twice. Idempotent handlers tolerate duplicates without side effects.
The standard pattern: dedupe on a stable delivery ID before doing any work.
async function handleEvent(payload) {
const { event } = payload;
const { jobId, outputUrl, errorMessage } = payload.data;
// One-off webhookUrl callbacks carry no deliveryId
const key = payload.deliveryId ?? `${event}:${jobId}`;
// Atomic insert. If the key 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
`, [key]);
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, errorMessage);
}
}Stripe gives the same advice for its own events: guard against duplicates "by logging the event IDs you've processed, and then not processing already-logged events" (Stripe Docs, 2026). Without it, duplicates cause double S3 uploads, duplicate emails and double-counted usage.
Keep processed IDs for longer than any delivery can arrive, including your reconciliation job. Renderly's own retries end within seconds, so a few days of retention is ample and costs little.
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: Renderly waits 10 seconds for your response, and GitHub sets the same bar - "Your server should respond with a 2XX response within 10 seconds of receiving a webhook delivery" (GitHub Docs, 2026). A handler that copies the MP4 to your bucket, regenerates thumbnails and sends emails inline can blow past that, and every timeout becomes a retry.
The right pattern: the handler verifies, dedupes and returns a 2xx, and a queue worker (BullMQ, SQS, Inngest or similar) does the actual work. Stripe recommends the same: "Configure your handler to process incoming events with an asynchronous queue."
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. Two options:
Option A - ngrok. ngrok http 3000 gives you a public URL like https://abc123.ngrok-free.app. Best for quick testing.
Option B - Cloudflare Tunnel. cloudflared tunnel --url http://localhost:3000 creates a quick tunnel, with named tunnels and custom domains available for ongoing development against a real Renderly account.
Once tunneled, open Settings, Webhooks in the Renderly dashboard (or call POST /api/v1/webhooks), register the tunnel URL, copy the signing secret it shows once, and queue a test render. Every registered delivery is signed, so your local handler exercises the same verification path as production.
Common Pitfalls
Security
- Signing the parsed body (re-serialized JSON) instead of raw bytes - covered above, this is the #1 bug.
- Calling
timingSafeEqualwithout a length check - a forged short signature throws and returns a 500 instead of a 401. - 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 it, a captured delivery can be replayed indefinitely. Reject any payload where
|now - X-Renderly-Timestamp| > 300seconds. - Trusting one-off
webhookUrlcallbacks - they have no secret behind them. Use a registered webhook, or an unguessable token in the callback URL.
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 reconciliation job - with a retry window of seconds, a deploy can drop deliveries. Poll
GET /api/v1/renders/:jobIdfor anything still unconfirmed. - Crashing instead of answering - if your database is briefly down, return a 5xx so the delivery is retried. A handler that hangs burns the full 10-second timeout per attempt.
Operations
- Logging full payloads - treat
outputUrlas a credential. Anyone with the link can download the video, so redact or hash it in logs. - Relying on
outputUrlforever - copy the MP4 to your own storage when the webhook lands, instead of linking to the render output for the long term. - No monitoring - track delivery success rate and how often reconciliation finds a render the webhook missed. A rising number means something is wrong upstream.
The webhook handler is the production seam between your application and an async video API. Verify the signature over the raw body with a length-safe compare, make the handler idempotent, keep a reconciliation poll, and don't do work inside the handler. Those rules cover most 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, and pay-as-you-go credits that never expire.
Frequently asked
Why use webhooks instead of polling for video renders?
How do I verify a webhook signature in Node.js?
What retry behavior should I expect from webhook providers?
Why does my webhook handler need to be idempotent?
How do I test webhooks locally without deploying?
Should webhook handlers do work synchronously?
Related Articles

How to Generate 1,000+ Personalized Videos with API Automation (2026 Update)
A 30-second 1080p render costs $0.10 on a template API and $1.50+ on a generative one. Build the bulk pipeline: 1,000 personalized videos queued in under 20 minutes.
May 18, 2026

The Complete Guide to Automating Video Creation in 2026
Automate video creation in 2026 with templates, APIs, and AI agents. An expert production company charges about $1,000/min; a template render, $0.10-0.20.
July 28, 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)
Build a no-code Make.com video pipeline in 30 minutes. Make meters in credits now - 1 module run costs 1 credit, so a 5-module render scenario runs 5 per video.
August 24, 2026

4 Best Video APIs for Developers (November 2026)
We compared Renderly, Shotstack, Creatomate and JSON2Video on price, plans and DX. Entry plans run $0.145 to $0.84 per minute of 1080p video.
June 8, 2026