Webhooks
Zvid sends render.completed and render.failed events to public HTTPS
endpoints. Use a registered webhook for signed account-wide notifications, or
pass an unsigned per-request webhookUrl for individual jobs.
| Delivery type | Scope | Signature |
|---|---|---|
| Registered webhook | Every matching event for your account | HMAC-SHA256 using the endpoint's secret |
Per-request webhookUrl | The submitted job; one notification per child job for bulk | Unsigned; no X-Zvid-Signature header |
Both can be used together, so the same job can produce more than one delivery. A failed notification does not change a completed render's status.
Registering a webhook
This is a complete request. Replace the example receiver URL with your endpoint:
curl -X POST https://api.zvid.io/api/webhooks \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/zvid",
"events": ["render.completed", "render.failed"]
}'
The response is the created endpoint object. Store its secret
on your server for signature verification. All management routes accept an
API key or a dashboard JWT; use an API key for server-to-server integration.
| Endpoint | Purpose |
|---|---|
GET /api/webhooks | List: { webhooks, usage } |
POST /api/webhooks | Create: endpoint object, including secret |
GET /api/webhooks/{id} | Details: { webhook } |
PUT /api/webhooks/{id} | Update url, description, events, or status (active / disabled) |
DELETE /api/webhooks/{id} | Delete: HTTP 200, { deleted: true } |
GET /api/webhooks/{id}/deliveries | Delivery log: { deliveries } |
POST /api/webhooks/{id}/test | Queue a sample event: { queued: true, deliveryId } |
URLs must resolve to public addresses. Hosted production requires HTTPS; local/private hosts and redirects are not supported. Endpoint counts are plan-limited; Free permits one. See the dashboard guide to manage them visually.
The delivery
Each delivery is a JSON POST with these headers:
| Header | Value |
|---|---|
X-Zvid-Event | render.completed or render.failed |
X-Zvid-Delivery-Id | Delivery identifier; stable across automatic attempts |
X-Zvid-Timestamp | Unix time in seconds when this attempt was signed |
X-Zvid-Job-Id | Render job ID |
X-Zvid-Signature | Registered webhooks only: sha256=<hex HMAC> |
The body is an event envelope, not the response from GET /api/jobs/{id}.
A representative completion event is:
{
"event": "render.completed",
"jobId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-09-22T10:00:00.000Z",
"data": {
"status": "completed",
"url": "https://cdn.zvid.io/videos/example.mp4",
"thumbnailUrl": "https://cdn.zvid.io/images/example.jpg",
"duration": 10,
"size": 3848989,
"creditsCharged": 10,
"templateId": null
}
}
Read the output URL at data.url. The body's ISO timestamp is the event
creation time; the signature uses the header's Unix timestamp. A failure
has event: "render.failed" and data.status, data.error, and
data.creditsCharged. Test deliveries additionally carry test: true and use
sample output data; do not treat them as production renders.
Verifying the signature
Compute HMAC-SHA256(secret, "<header timestamp>.<raw body>"). Preserve the
received bytes: parsing and re-serializing JSON can change the signature.
This complete Node.js receiver requires Express and a server-side
ZVID_WEBHOOK_SECRET environment variable:
const express = require("express");
const crypto = require("node:crypto");
const app = express();
const secret = process.env.ZVID_WEBHOOK_SECRET;
if (!secret) throw new Error("Set ZVID_WEBHOOK_SECRET");
// Mount this route BEFORE any app.use(express.json()).
app.post(
"/hooks/zvid",
express.raw({ type: "application/json", limit: "1mb" }),
(req, res) => {
const timestamp = req.get("x-zvid-timestamp") || "";
const signature = req.get("x-zvid-signature") || "";
const seconds = Number(timestamp);
if (
!/^\d+$/.test(timestamp) ||
!Number.isSafeInteger(seconds) ||
Math.abs(Date.now() / 1000 - seconds) > 300 ||
!/^sha256=[a-f0-9]{64}$/i.test(signature) ||
!Buffer.isBuffer(req.body)
) {
return res.sendStatus(401);
}
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + ".")
.update(req.body)
.digest();
const received = Buffer.from(signature.slice(7), "hex");
if (
received.length !== expected.length ||
!crypto.timingSafeEqual(received, expected)
) {
return res.sendStatus(401);
}
let event;
try {
event = JSON.parse(req.body.toString("utf8"));
} catch {
return res.sendStatus(400);
}
// Demonstration only: production receivers must persist/enqueue the event
// durably, deduplicated by x-zvid-delivery-id, before acknowledging it.
console.log(event.event, event.jobId);
return res.sendStatus(204);
},
);
app.listen(3000);
The five-minute age check is a receiver policy for replay protection; synchronize your server's clock. Reject absent or malformed signatures on the signed route. The receiver intentionally rejects unsigned per-request callbacks.
Retries and duplicate handling
Zvid allows five total attempts, including the first. Retry delays are 30 seconds, 60 seconds, 2 minutes, and 4 minutes; scheduling can add delay. Each attempt times out after 10 seconds. Any non-2xx response or transport failure counts as a failed attempt. Redirects are not followed.
A successful side effect followed by a lost response can lead to a duplicate.
Persist X-Zvid-Delivery-Id with your work, and deduplicate repeated attempts.
If you configure multiple endpoints or both delivery types, also make your
business action idempotent for the job and event. Acknowledge only after durable
acceptance, then perform longer work asynchronously.
After 20 consecutive deliveries exhaust their retries, a registered endpoint is disabled. Inspect its delivery log, fix the receiver, and re-enable it. See Errors and retries.
Per-request webhookUrl
This complete render request creates a one-second video and requests completion or failure notification at the supplied URL:
curl -X POST https://api.zvid.io/api/render/api-key \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"payload": {
"duration": 1,
"visuals": [{ "type": "TEXT", "text": "Hello", "position": "center-center" }]
},
"webhookUrl": "https://example.com/hooks/render-done"
}'
Per-request callbacks use the same event envelope and retry schedule, but they are unsigned. Treat them as a notification hint and retrieve the job using your own API key before trusting the output or performing an action. Prefer a registered signed webhook when you need authenticated delivery.
On bulk submissions, the callback fires per child job, not once for the entire batch. Track aggregate progress with the bulk endpoint.