Engineering
Video Rendering Webhooks: A Production Zvid Implementation
Implement Zvid video rendering webhooks with raw-body HMAC checks, replay protection, idempotency, retries, delivery logs, and job reconciliation.
Published July 18, 2026

Video Rendering Webhooks: A Production Zvid Implementation
Video rendering webhooks let your application react when a Zvid job reaches a terminal state. Zvid sends render.completed or render.failed as an HTTP POST, includes the job object in the body, and exposes delivery attempts in the dashboard. A registered endpoint also receives the HMAC material needed to verify that the request came from Zvid.
The receiver still needs careful engineering. It must verify the raw bytes before parsing, reject stale requests, return quickly, tolerate retries, deduplicate side effects, and reconcile missing events against the job API. A webhook is an at-least-once notification mechanism—not a remote function call that runs exactly once.
The header names, signing format, retry schedule, and endpoint behavior in this guide come from the current official Zvid webhook documentation.

Verify, deduplicate, persist, acknowledge, then process the render event.
The event lifecycle
A production flow should look like this:
- Your application creates a local render record with its own source ID.
- It submits a Zvid render and stores the returned job ID.
- Zvid validates, queues, and renders the project.
- Zvid sends a terminal webhook.
- Your edge handler verifies the signature and timestamp.
- It durably records the event and returns a 2xx response.
- An internal worker reconciles the event with the local render state.
- Downstream delivery or publication runs idempotently.
The status endpoint remains useful. Webhooks remove constant polling from the normal path; they do not eliminate reconciliation.
Registered webhooks versus webhookUrl
Zvid supports two delivery paths, and they can be used together.
Registered webhook
Create an account-level endpoint with /api/webhooks or in the dashboard. Select the terminal events it should receive. Zvid returns a secret used for HMAC verification. The endpoint receives every matching job event while it is active.
Use this for a stable production integration because it provides:
- A reusable secret
- Event selection
- Active/inactive management
- Test delivery
- Delivery history with status, attempts, and response codes
Per-request webhookUrl
Pass a one-off URL on an individual render or bulk submission. It receives completion for that job only.
{
"template": "tpl_xxxxxxxxxxxxxxxxxxxx",
"variables": {
"title": "July product update"
},
"webhookUrl": "https://app.example.com/hooks/zvid/request-42"
}
Per-request callbacks are convenient for routing or short-lived integrations. The documented HMAC verification secret is returned when an endpoint is registered, so use a registered webhook when cryptographic source verification is a requirement. Do not put credentials in a callback URL.
Register the endpoint and protect the secret
curl -X POST https://api.zvid.io/api/webhooks \
-H "x-api-key: $ZVID_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/hooks/zvid",
"events": ["render.completed", "render.failed"]
}'
The response includes the secret. Capture it once, place it in a secret manager, and do not write it to application logs. Store the webhook ID separately; it is needed to update, test, inspect, or delete the endpoint.
Zvid rejects private hosts, local addresses, and non-standard ports as an SSRF defense. Development receivers therefore need a public HTTPS tunnel or a deployed test endpoint.
Know the delivery contract
Zvid sends these headers:
| Header | Meaning |
|---|---|
X-Zvid-Event |
render.completed or render.failed |
X-Zvid-Delivery-Id |
Unique delivery identifier |
X-Zvid-Timestamp |
Unix timestamp in seconds used for signing |
X-Zvid-Job-Id |
Render job ID |
X-Zvid-Signature |
sha256=<hex HMAC> for a registered webhook with a secret |
The JSON body is the same job object available from GET /api/jobs/{id}. A completed job contains its output under result; a failed job contains failedReason.
Persist the whole verified event or the fields needed to reconstruct it. Do not assume a display filename is a unique key; use the Zvid job ID and your own source identifier.
Verify the exact raw body
The signing input is:
HMAC-SHA256(secret, "<timestamp>.<raw body>")
The result is hex-encoded and prefixed with sha256=. JSON parsing and re-serialization can change whitespace or key order, so verification must use the exact request bytes.
Here is a defensive Node.js verifier:
import crypto from 'node:crypto';
function verifyZvidSignature(input: {
rawBody: Buffer;
timestampHeader?: string;
signatureHeader?: string;
secret: string;
nowSeconds?: number;
}) {
const {
rawBody,
timestampHeader,
signatureHeader,
secret,
nowSeconds = Math.floor(Date.now() / 1000),
} = input;
if (!timestampHeader || !signatureHeader) return false;
if (!/^\d+$/.test(timestampHeader)) return false;
if (!/^sha256=[0-9a-f]{64}$/i.test(signatureHeader)) return false;
const timestamp = Number(timestampHeader);
if (Math.abs(nowSeconds - timestamp) > 300) return false;
const signed = Buffer.concat([
Buffer.from(`${timestampHeader}.`, 'utf8'),
rawBody,
]);
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(signed).digest('hex');
const receivedBuffer = Buffer.from(signatureHeader, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return (
receivedBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(receivedBuffer, expectedBuffer)
);
}
The five-minute window is the example recommended in Zvid's docs. Keep server clocks synchronized. Compare equal-length buffers with timingSafeEqual; calling it on different lengths throws.
Configure the framework before its JSON parser
In Express, mount a raw-body route before a global JSON parser consumes the stream:
import express from 'express';
const app = express();
app.post(
'/hooks/zvid',
express.raw({ type: 'application/json', limit: '1mb' }),
async (req, res) => {
const rawBody = req.body as Buffer;
const valid = verifyZvidSignature({
rawBody,
timestampHeader: req.header('x-zvid-timestamp') ?? undefined,
signatureHeader: req.header('x-zvid-signature') ?? undefined,
secret: process.env.ZVID_WEBHOOK_SECRET!,
});
if (!valid) return res.status(401).send('invalid signature');
let job: unknown;
try {
job = JSON.parse(rawBody.toString('utf8'));
} catch {
return res.status(400).send('invalid json');
}
await webhookInbox.insertIfAbsent({
deliveryId: req.header('x-zvid-delivery-id')!,
eventType: req.header('x-zvid-event')!,
jobId: req.header('x-zvid-job-id')!,
body: job,
});
res.status(204).end();
},
);
app.use(express.json());
Adapt the raw-body mechanism to your framework. Some platforms expose raw bytes separately; others require disabling automatic body parsing for the route.
Acknowledge after durable receipt, not after every side effect
Do not resize assets, send emails, update a CRM, and publish a campaign inside the HTTP handler. A slow dependency can cause Zvid to retry even though some side effects already happened.
Use an inbox table:
create table webhook_inbox (
delivery_id text primary key,
event_type text not null,
job_id text not null,
body jsonb not null,
received_at timestamptz not null default now(),
processed_at timestamptz,
processing_error text
);
Insert with a unique constraint, commit, and return 2xx. A separate worker processes unhandled rows. This creates a durable boundary between Internet delivery and application work.
If the same delivery arrives again, the insert becomes a no-op and the handler can still return 2xx. Also make downstream operations idempotent by using stable keys such as render:{jobId}:ready.
Reconcile the event with your local job state
Your application should know about the job before the event arrives:
type LocalRender = {
id: string;
sourceId: string;
zvidJobId: string;
status: 'submitting' | 'queued' | 'completed' | 'failed';
outputUrl?: string;
error?: string;
};
Process terminal events as monotonic state transitions:
async function processRenderEvent(event: StoredWebhook) {
const render = await renders.lockByZvidJobId(event.jobId);
if (!render) throw new RetryableError('job not recorded yet');
if (render.status === 'completed' || render.status === 'failed') {
return;
}
if (event.eventType === 'render.completed') {
const outputUrl = getOutputUrl(event.body);
await renders.markCompleted(render.id, outputUrl);
await outbox.enqueue('render.ready', {
renderId: render.id,
sourceId: render.sourceId,
outputUrl,
});
} else {
await renders.markFailed(render.id, getFailedReason(event.body));
}
}
Validate that the job ID in the header matches the body. Treat the body as untrusted even after signature verification: a valid sender can still deliver a shape your code did not expect after an API change or bug.
Understand retries and the delivery log
Zvid treats an unreachable endpoint or non-2xx response as a failed attempt. The current documented retry sequence is up to five attempts with exponential delays of approximately 30 seconds, 60 seconds, 2 minutes, and 4 minutes after the initial attempt.
Every registered-webhook attempt appears in the delivery log with status, attempts, and response codes. Use it to answer:
- Did Zvid send the event?
- Which endpoint version received it?
- What response code did the receiver return?
- Is the receiver still retrying a permanent 4xx error?
Return:
2xxafter the verified event is durably recorded.400for malformed JSON that should not be retried indefinitely.401for missing or invalid signatures.5xxonly when a transient failure prevents durable receipt.
Do not return 2xx before the inbox insert succeeds, or the event can be lost.
Reconcile events that never reach your app
Networks and deployments fail in ways retries cannot always bridge. Run a periodic reconciliation job:
- Find local renders still non-terminal beyond an expected age.
- Fetch each Zvid job with
GET /api/jobs/{id}. - Apply the same terminal transition used by the webhook worker.
- Record that reconciliation, not webhook delivery, resolved the job.
- Alert on a sustained gap between terminal Zvid jobs and received events.
Use slower reconciliation, such as every few minutes, rather than returning to tight polling loops. Webhooks remain the fast path; the job API is the repair path.
Bulk rendering still produces job-level events
The Zvid bulk rendering endpoint fans one template and many variable sets into individual jobs. Each accepted item has its own status, output, and terminal webhook.
Store three identities:
- Your source record ID
- The Zvid bulk ID
- The Zvid job ID for the accepted item
Do not mark the whole campaign successful because one item completed. Reconcile each item, retain itemErrors from initial bulk validation, and compute batch status from local item rows.
Test the unhappy paths
The dashboard exposes a test-delivery action for registered webhooks. A production readiness test should cover more than one 200 response:
- Valid signature and current timestamp
- One changed body byte
- Missing signature header
- Timestamp older than the replay window
- Duplicate delivery ID
- Valid event for an unknown job
- Completed event processed twice
- Temporary database outage that returns 5xx, then succeeds
- Receiver timeout and retry
- Completed job recovered by reconciliation when no event is processed
Keep signature fixtures as raw byte buffers in tests. Reformatting the JSON fixture changes the expected HMAC.
Define the webhook event and trigger contract
A render webhook is an automated POST request that tells an external endpoint that a state change occurred. The trigger is not “a user probably finished a task”; it is a specific render event with a specific job identity. Your receiver should validate the signature, parse the JSON data, acknowledge quickly, and move business processing to its own queue.
For a completion event, store enough information to answer: which job completed, whether it completed successfully, which output asset belongs to it, and whether this event was already handled. A failed event should preserve the renderer’s failure details without treating every failure as retryable. Whenever a video changes state, the application ledger—not the webhook delivery itself—should decide the next legal transition.
That separation keeps analytics and publishing reliable. The rendering process can notify your service, but the service decides whether to update a campaign, release a file, or request review. Webhook requests may be retried, duplicated, or delivered after an application-side timeout. Idempotency and reconciliation are therefore part of the integration, not optional cleanup.
Real Zvid example: event-driven render completion
The video below is a Zvid-rendered adaptation of the saas-api-explainer example. It shows the webhook lifecycle — register a receiver for render.completed and render.failed, receive the signed completion event with its job ID and result URL, then verify the raw body before processing. The media is a real Zvid render.
<video controls playsinline preload="metadata" poster="https://cdn.zvid.io/images/1/blog-14-demo_thumbnail-1784365530211.jpg" aria-label="Zvid-rendered saas-api-explainer demonstration for Video Rendering Webhooks" style="width:100%;height:auto;"> <source src="https://cdn.zvid.io/videos/1/blog-14-demo-1784365529973.mp4" type="video/mp4"> Your browser does not support embedded video. </video>
A real Zvid render adapted from the published saas-api-explainer example for this workflow.
The inline lifecycle image is also rendered through Zvid. It acts as the implementation map: Internet-facing verification is separated from durable receipt and asynchronous business processing.
Frequently asked questions
Does a video rendering webhook replace job polling completely?
It replaces continuous polling in the normal path. A slower reconciliation process should still query old non-terminal jobs so temporary delivery failures cannot leave records stuck forever.
Why must signature verification use the raw body?
Zvid signs the exact request bytes. Parsing and re-serializing JSON can change whitespace or key order, producing a different HMAC even when the data is equivalent.
Should webhook retries repeat downstream actions?
No. Store the delivery with a unique key and make every downstream transition idempotent. A duplicate event should safely become a no-op.
How do bulk render webhooks work?
Each accepted bulk item becomes an individual render job and emits its own terminal event. Track source, bulk, and job IDs so every output can be reconciled independently.
Treat the webhook as a durable event boundary
The reliable pattern is simple: verify, persist, acknowledge, then process. Zvid supplies terminal events, HMAC signing for registered endpoints, retries, a delivery log, test delivery, and the job API. Your application supplies idempotency, business-state transitions, output retention, and reconciliation.
That division removes wasteful status loops without pretending distributed delivery is exactly once.