# Guide to api.video Webhooks: Features and Best Practices

api.video is an API for uploading, encoding, hosting and live streaming video. Most of its work is asynchronous: an upload has to be transcoded into several renditions, a live stream has to start, end and be recorded, and captions and summaries are generated by AI after the fact. Webhooks are how your application finds out when each of those steps finishes, so it can switch on playback, update a player or publish a transcript without polling the API.

This guide covers api.video's six events, why one upload produces several encoding webhooks, how to verify `X-Api-Video-Signature` when you have more than one subscription, and what api.video's very short retry window means for your receiver.

## What are api.video webhooks?

A webhook in api.video is a subscription: a URL and a list of events. When one of those events happens in the project, api.video sends a `POST` with a JSON body to the URL. You can create subscriptions in the dashboard or through the API, list and delete them, and look up any one by its ID. There is no update call, so changing the events means deleting the subscription and creating a new one.

Each subscription gets its own signature secret, prefixed `sig_sec_`. Every delivery carries two headers: `X-Api-Video-WebhookID`, which names the subscription that sent it, and `X-Api-Video-Signature`, a hex HMAC-SHA256 of the body keyed with that subscription's secret.

## api.video webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (**Project settings > Webhooks**) or `POST https://ws.api.video/webhooks` |
| Management | List, retrieve and delete. No update endpoint |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-api-video-signature` |
| Subscription header | `X-Api-Video-WebhookID` |
| Signed content | The raw request body only |
| Secret | One `signatureSecret` (`sig_sec_...`) per subscription |
| Body | JSON |
| Event routing | Body field `type` |
| Success | Any `1xx` or `2xx` response |
| Retries | 3 retries at 3-second intervals for any response above `299` |
| Timeout | Not documented |
| Source IPs | Not published |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `video.source.recorded` | The video's source file has been recorded | Confirm an upload landed |
| `video.encoding.quality.completed` | A rendition finished encoding | Enable playback at that quality |
| `video.caption.generated` | A caption was generated | Publish captions |
| `video.summary.generated` | A summary was generated | Store the summary |
| `live-stream.broadcast.started` | A broadcast started | Notify viewers, mark live |
| `live-stream.broadcast.ended` | A broadcast ended | Finalize the recording |

api.video's own description of `video.source.recorded` is narrower than its name suggests: it fires when "a live stream has been completed and the recording of the live stream is ready to be transcoded". It is the bridge between the live stream events and the encoding events for the recording.

`video.encoding.quality.completed` is the event you will receive most. The MP4 asset is transcoded once, so it sends one webhook at the final source quality. The HLS asset sends one webhook for each quality from 240p up to the source quality, up to 4K. api.video's example is a 720p upload, which produces five webhooks: one for MP4 at 720p and four for HLS at 240p, 360p, 480p and 720p. Use `encoding` and `quality` to decide when a video is ready for your purposes, rather than treating the first webhook as "done".

### Event Payload Structure

Bodies are flat. `type` and `emittedAt` sit beside the event's own fields, such as `videoId`, `liveStreamId`, `encoding` and `quality`. The `X-Api-Video-WebhookID` header names the subscription, not the event.

### Published payload example

This is the `video.encoding.quality.completed` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/api-video/latest/video.encoding.quality.completed.json). It is api.video's documentation example, not a captured delivery.

```json
{
  "type": "video.encoding.quality.completed",
  "emittedAt": "2021-01-29T15:46:25.217Z",
  "videoId": "vi0000000000000000000000",
  "liveStreamId": "li0000000000000000000000",
  "encoding": "hls",
  "quality": "720p"
}

```

Only this event has an example on api.video's webhook guide. The API reference describes the others, so log a real delivery of each event you handle before relying on its fields.

## Setting up api.video webhooks

In the dashboard, open **Project settings**, then the **Webhooks** tab, and add your URL with the events you want. The same tab lists your subscriptions, filters them by event and deletes them.

To create a subscription from code, call the API with your API key:

```bash
curl --request POST \
  --url https://ws.api.video/webhooks \
  --header 'Authorization: Bearer {your API key}' \
  --header 'Content-Type: application/json' \
  --data '{"events": ["video.encoding.quality.completed", "live-stream.broadcast.started"], "url": "https://example.com/webhooks/api-video"}'

```

The `201` response includes `webhookId` and `signatureSecret`. Store the secret server-side, for example as `WEBHOOK_SECRET`. You can read it again later with `GET /webhooks/{webhookId}`, using the ID from the `X-Api-Video-WebhookID` header if you need to find out which subscription sent a request. api.video's sandbox environment (`sandbox.api.video`) has its own keys and its own webhooks, so test there before you subscribe in production.

To receive sandbox deliveries on your machine, forward them with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 api-video --path /webhooks/api-video

```

## Securing api.video webhooks

Read `X-Api-Video-Signature`, compute an HMAC-SHA256 of the raw request body with the subscription's secret as the key, encode it as hex and compare the two in constant time. api.video warns that the body must be byte-identical to what it sent, so don't parse it first. See the [verification documentation](https://docs.api.video/reference/create-and-manage-webhooks#verify-webhook-origins).

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-api-video-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

If you run more than one subscription, each has a different secret. The handler below picks the secret by the `X-Api-Video-WebhookID` header, with `WEBHOOK_SECRET` as the fallback for a single subscription, and stores the event before answering.

```javascript
const express = require('express');

// e.g. { "webhook_1BWt3JArmQq9sNZzsHStRS": "sig_sec_..." }
const secretsByWebhookId = JSON.parse(process.env.API_VIDEO_WEBHOOK_SECRETS || '{}');

function createApiVideoApp(saveEvent) {
  const app = express();
  app.post('/webhooks/api-video', express.raw({ type: '*/*' }), async (req, res) => {
    const subscriptionId = req.get('x-api-video-webhookid');
    const secret = secretsByWebhookId[subscriptionId] || process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    let event;
    try {
      event = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!event || typeof event !== 'object' || Array.isArray(event)) return res.sendStatus(400);
    if (typeof event.type !== 'string') return res.sendStatus(400);
    try {
      if (event.type === 'video.encoding.quality.completed') {
        await saveEvent('rendition', { videoId: event.videoId, encoding: event.encoding, quality: event.quality });
      } else {
        await saveEvent(event.type, event);
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Only the body is signed. `emittedAt` is inside the body, and api.video sends no separate signed timestamp, so a captured request verifies for as long as the secret is valid.

Hookdeck's api.video source type verifies the same header, a hex HMAC-SHA256 of the raw body, using the secret you paste into the source. Hookdeck always answers api.video with `200`, records a mismatch as failed verification and does not forward that request. There is no timestamp check on Hookdeck's side either. Because the secret is per subscription, use one Hookdeck source per api.video subscription.

> **Make api.video webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) accepts every api.video delivery straight away, verifies `X-Api-Video-Signature`, and keeps each encoding and live stream event until your service has processed it.

## api.video webhook limitations and pain points

### All retries happen within about ten seconds

**The Problem:** A short restart or deploy on your side loses encoding and broadcast events for good.

**Why It Happens:** api.video retries an unsuccessful delivery 3 times with 3-second intervals between tries. Any status above `299` counts as unsuccessful. After the last try, there is no further attempt and no documented way to redeliver.

**Workarounds:**

* Keep the receiver highly available, and return `2xx` as soon as the event is stored.
* Reconcile periodically: list videos with the API and check their status, so a missed `video.encoding.quality.completed` doesn't leave a video unpublished.

**How Hookdeck Can Help:** Hookdeck accepts each delivery on api.video's first attempt and stores it, then retries your service on a schedule you choose, so your downtime costs latency instead of events.

### Many encoding webhooks for one upload

**The Problem:** Your handler marks a video as ready after the first webhook, and viewers get 240p.

**Why It Happens:** HLS sends one `video.encoding.quality.completed` per rendition, from the lowest quality up, and MP4 sends its own. A single 720p upload produces five webhooks.

**Workarounds:**

* Track renditions per `videoId` and act when the quality you need has arrived.
* Use the `encoding` field to tell HLS and MP4 apart.

**How Hookdeck Can Help:** Hookdeck filters can match on `encoding` and `quality`, so only the renditions you act on reach your service.

### A secret per subscription

**The Problem:** Verification fails for some deliveries and passes for others.

**Why It Happens:** Every subscription has its own `signatureSecret`. Two subscriptions pointing at the same URL sign with different keys, and a recreated subscription gets a new one.

**Workarounds:**

* Map secrets by `X-Api-Video-WebhookID`, as in the handler above.
* When you delete and recreate a subscription to change its events, update the stored secret at the same time.

**How Hookdeck Can Help:** Give each subscription its own Hookdeck source with its secret, and route them all to one destination.

### No timeout, IP list or event IDs

**The Problem:** You can't firewall the endpoint, tune your response time to a known limit, or deduplicate on an event ID.

**Why It Happens:** api.video's webhook documentation states no timeout, publishes no source IPs, and puts no event ID in the body.

**Workarounds:**

* Treat the signature as the only proof of origin.
* Answer quickly, without waiting on downstream work.
* Build an idempotency key from `type`, the resource ID, `encoding` and `quality`.

**How Hookdeck Can Help:** Hookdeck's deduplication can use a combination of body fields as the key, so retried deliveries of the same rendition are dropped before your service sees them.

## Best practices

### Verify the exact bytes

The signature covers the body as sent. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `await request.arrayBuffer()`. In FastAPI, use `await request.body()`. Parse JSON only after verification.

### Decide what "ready" means

Pick the rendition your product needs, for example HLS at 720p or the MP4, and only flip a video to published when that `quality` arrives. Keep a record of renditions received so late or retried webhooks don't move the state backwards.

### Close the loop on live streams

Use `live-stream.broadcast.started` and `live-stream.broadcast.ended` to mark a stream as live, and `video.source.recorded` to know a recording is on its way. The recording's own encoding webhooks follow.

### Acknowledge fast and process asynchronously

With retries at 3-second intervals, a slow handler can burn through api.video's attempts before it ever answers. Verify, store and return `200`, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make processing idempotent

Retries resend the same body. Key work on `videoId` plus `encoding` and `quality`, or on `liveStreamId` plus `type`. More patterns are in our [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

api.video's webhooks are simple to verify and easy to subscribe to, but its delivery is unforgiving: three retries a few seconds apart, then nothing. Treat the encoding webhooks as a stream of renditions rather than a single "done" signal, map secrets by subscription ID, and keep the endpoint answering fast.

[Hookdeck Event Gateway](https://hookdeck.com) gives api.video an endpoint that is always up, verifies each signature and holds the event until your service is ready for it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle api.video webhooks reliably in minutes.