Skip to main content

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 typeScopeSignature
Registered webhookEvery matching event for your accountHMAC-SHA256 using the endpoint's secret
Per-request webhookUrlThe submitted job; one notification per child job for bulkUnsigned; 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.

EndpointPurpose
GET /api/webhooksList: { webhooks, usage }
POST /api/webhooksCreate: 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}/deliveriesDelivery log: { deliveries }
POST /api/webhooks/{id}/testQueue 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:

HeaderValue
X-Zvid-Eventrender.completed or render.failed
X-Zvid-Delivery-IdDelivery identifier; stable across automatic attempts
X-Zvid-TimestampUnix time in seconds when this attempt was signed
X-Zvid-Job-IdRender job ID
X-Zvid-SignatureRegistered 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.