---
title: "Webhooks"
canonical_url: https://docs.zvid.io/docs/automation/webhooks/
source: docs/automation/webhooks.md
content_revision: 6338386a1dd87e4a
---

# 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:

```bash
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](https://docs.zvid.io/docs/dashboard/webhooks/) 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:

```json
{
  "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:

```javascript
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](https://docs.zvid.io/docs/operations/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:

```bash
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](https://docs.zvid.io/docs/automation/bulk-rendering/), the callback fires per child job,
not once for the entire batch. Track aggregate progress with the bulk endpoint.

## Related

- [Webhooks in the dashboard](https://docs.zvid.io/docs/dashboard/webhooks/)
- [Bulk rendering](https://docs.zvid.io/docs/automation/bulk-rendering/)
- [Render lifecycle](https://docs.zvid.io/docs/operations/render-lifecycle/)
