# Webhooks

> Be called when a file is done or failed and when a project stops, instead of polling.

A webhook is an HTTPS URL of yours that the API POSTs an event to when something happens: a file
is done or failed, a project stops, or its processing service fails. Each delivery is signed, so
your server can tell it came from SyncWords, and repeated until your server takes it.

Polling [`GET /projects/{id}/files`](/vod-express/guides/monitoring) keeps working, and stays the way to catch
up after your server was down for longer than deliveries are retried.

## Create a webhook

```bash
curl -s -X POST "$API/webhooks" -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"url": "https://hooks.example.com/syncwords", "events": ["file.done", "file.failed", "project.stopped", "project.error"]}'
```

```json
{
  "data": {
    "id": "wh_01m3n2t4k8b5x0c7d2e9f6g1h3",
    "url": "https://hooks.example.com/syncwords",
    "events": ["file.done", "file.failed", "project.stopped", "project.error"],
    "createdAt": "2026-09-28T09:00:00.000Z",
    "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
  }
}
```

> **Warning:**
> **The `secret` is shown only in this answer.** Store it with your server's other secrets; it is
> what you verify each delivery with. Lost it, or leaked it? [Rotate it](#rotate-the-secret).

The URL must be `https://` and reach a public address: private networks, loopback and link-local
addresses are refused, when you create the webhook and again at every delivery, and so are host
names without a dot and names under `.local` or `.internal`. An account has at
most 10 webhooks, one per URL. A webhook receives the events that happen after it was created,
for every project of the account.

| Call | What it does |
|---|---|
| `GET /webhooks` | Your webhooks. |
| `GET /webhooks/{id}` | One of them. |
| `PATCH /webhooks/{id}` | Changes its `url`, its `events`, or both. The id and the secret stay; deliveries not yet made go to the new URL. |
| `DELETE /webhooks/{id}` | Stops it at once; deliveries not yet made are dropped. |
| `POST /webhooks/{id}/rotate-secret` | A new secret; the old one keeps signing for 24 hours. [Rotate the secret](#rotate-the-secret). |
| `POST /webhooks/{id}/test` | Sends a `webhook.test` event now and answers what your server said. |
| `GET /webhooks/{id}/deliveries` | The deliveries of the past 7 days, newest first, with attempts and your server's answers. Paged (`page`, `perPage`); `?status=pending`, `delivered` or `failed`. |
| `POST /webhooks/{id}/deliveries/{eventId}/retry` | Sends that delivery again now, with a fresh 24 hours of retries. |

## Events

Every delivery is a JSON body of the same shape:

```json
{
  "id": "evt_01m3n2x5f1h2d9k7c3b8a6w4q0",
  "type": "file.done",
  "timestamp": "2026-09-28T10:00:04.000Z",
  "data": { … }
}
```

`id` is the event's, the same on every attempt: keep the ids you have handled and drop a repeat.
`timestamp` is when it happened. Events can arrive out of order; compare `timestamp` if the order
matters to you.

**Payloads only grow.** Fields may be added to `data`, and new event types added, without notice;
none is removed or changes meaning. Ignore what you do not know. A change that would break that
comes as a new event type (`file.done.v2`, say), sent beside the old one for as long as it takes to
move.

| `type` | When | `data` |
|---|---|---|
| `file.done` | A file finished processing. | `project`, and `file`: `name`, `hash`, `outputs`, `completedAt` |
| `file.failed` | A file could not be processed. | `project`, and `file`: `name`, `failureReason`, `failedAt` |
| `project.stopped` | A running project stopped. | `project`, and `reason`: `requested` (`POST …/stop`), `idle` (stopped 30 minutes after its last file, or after its service read `failed` for 2 hours) or `deleted` |
| `project.error` | The processing service did not start, or stopped working. | `project`, `reason` (`launch_failed` or `service_failed`) and `message` |
| `webhook.test` | You called `POST /webhooks/{id}/test`. | `webhook`: `id`, `url` |

```json file.done
{
"id": "evt_01m3n2x5f1h2d9k7c3b8a6w4q0",
"type": "file.done",
"timestamp": "2026-09-28T10:00:04.000Z",
"data": {
  "project": { "id": "01m3k8jnrdw509457nhz7w81bq", "name": "Fishing Channel S01" },
  "file": {
    "name": "season 1/episode 01.mp4",
    "hash": "8b548db6",
    "outputs": [
      { "path": "season 1/episode 01.es.mp4", "available": true, "size": 81234567 }
    ],
    "completedAt": "2026-09-28T10:00:04.000Z"
  }
}
}
```

```json file.failed
{
"id": "evt_01m3n30c2q7r4s9t6v1w8x3y5z",
"type": "file.failed",
"timestamp": "2026-09-28T10:02:11.000Z",
"data": {
  "project": { "id": "01m3k8jnrdw509457nhz7w81bq", "name": "Fishing Channel S01" },
  "file": {
    "name": "season 1/episode 02.mp4",
    "failureReason": "Processing this file failed. Retry it, or contact support if it fails again.",
    "failedAt": "2026-09-28T10:02:11.000Z"
  }
}
}
```

```json project.stopped
{
"id": "evt_01m3n3d7a1b2c3d4e5f6g7h8j9",
"type": "project.stopped",
"timestamp": "2026-09-28T10:32:40.512Z",
"data": {
  "project": { "id": "01m3k8jnrdw509457nhz7w81bq", "name": "Fishing Channel S01" },
  "reason": "idle"
}
}
```

```json project.error
{
"id": "evt_01m3n3k2m4n6p8q0r2s4t6v8w0",
"type": "project.error",
"timestamp": "2026-09-28T09:15:02.000Z",
"data": {
  "project": { "id": "01m3k8jnrdw509457nhz7w81bq", "name": "Fishing Channel S01" },
  "reason": "service_failed",
  "message": "The processing service stopped working. Start the project again, or contact support if it keeps failing."
}
}
```

`file.done`'s `hash` is the file's processing key: the same for every run of that name in the
project, retries and replacements included. Quote it to support; to tell runs apart, use
`completedAt`, and to tell events apart, `id`.

On `file.done`, download each output whose `available` is `true` with
`GET /projects/{id}/outputs/download?path=<path>`. On `file.failed`,
`POST /projects/{id}/files/{name}/retry` and `start` try it again. On `project.error`,
`POST /projects/{id}/start` does.

A project that reads a [feed](/vod-express/concepts/inputs#feeds) sends the same events. A
file's `name` is the one made from the feed item (`4f1c2b9a7d3e.8a2b4c6d.mp4`), and the files of an
output feed are among its `outputs`, under `feeds/<language>/`. By the time `file.done` is sent,
the output feed links the file, or will on its next rewrite, within 30 seconds. Such a project is
never stopped for being idle, so it sends `project.stopped` with `reason: "idle"` only after its
service has read `failed` for 2 hours.

## Verify the signature

Each delivery carries three headers, as the [Standard Webhooks](https://www.standardwebhooks.com)
specification has them:

| Header | Value |
|---|---|
| `webhook-id` | The event's `id`. |
| `webhook-timestamp` | When this attempt was sent, in seconds since 1970. |
| `webhook-signature` | `v1,` and the base64 HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<body>`, keyed with the secret's bytes: the base64 after `whsec_`. For 24 hours after a rotation, two of them, space-separated: the new secret's first. |

To verify one:

1. Read the body as raw bytes, before any JSON parsing: re-serialised JSON does not match.
2. Refuse a `webhook-timestamp` more than 5 minutes from your clock, so an old delivery cannot be
   replayed.
3. Compute the signature and compare it, in constant time, with each space-separated entry of
   `webhook-signature`.

A receiver that verifies, drops repeats, and answers at once:

```python receiver.py (Python 3.9+, no dependencies)
import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = base64.b64decode(os.environ["SW_WEBHOOK_SECRET"].removeprefix("whsec_"))
TOLERANCE = 5 * 60
handled = set()  # use your database in production

def verified(headers, body: bytes) -> bool:
  msg_id = headers.get("webhook-id", "")
  timestamp = headers.get("webhook-timestamp", "")
  if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE:
      return False
  signed = f"{msg_id}.{timestamp}.".encode() + body
  expected = "v1," + base64.b64encode(hmac.new(SECRET, signed, hashlib.sha256).digest()).decode()
  return any(
      hmac.compare_digest(expected, candidate)
      for candidate in headers.get("webhook-signature", "").split(" ")
  )

class Receiver(BaseHTTPRequestHandler):
  def do_POST(self):
      body = self.rfile.read(int(self.headers.get("content-length", 0)))
      if not verified(self.headers, body):
          self.send_response(401)
          self.end_headers()
          return
      event = json.loads(body)
      self.send_response(204)  # answer first, work after
      self.end_headers()
      if event["id"] in handled:
          return
      handled.add(event["id"])
      if event["type"] == "file.done":
          print("done:", event["data"]["file"]["name"])

HTTPServer(("", 8080), Receiver).serve_forever()
```

```javascript receiver.mjs (Node.js 20+, no dependencies)
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

const SECRET = Buffer.from(process.env.SW_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');
const TOLERANCE = 5 * 60;
const handled = new Set(); // use your database in production

function verified(headers, body) {
const id = headers['webhook-id'] ?? '';
const timestamp = headers['webhook-timestamp'] ?? '';
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE) {
  return false;
}
const expected = Buffer.from(
  'v1,' + createHmac('sha256', SECRET).update(`${id}.${timestamp}.`).update(body).digest('base64'),
);
return (headers['webhook-signature'] ?? '').split(' ').some((candidate) => {
  const given = Buffer.from(candidate);
  return given.length === expected.length && timingSafeEqual(given, expected);
});
}

createServer((request, response) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => {
  const body = Buffer.concat(chunks);
  if (!verified(request.headers, body)) {
    response.writeHead(401).end();
    return;
  }
  const event = JSON.parse(body);
  response.writeHead(204).end(); // answer first, work after
  if (handled.has(event.id)) return;
  handled.add(event.id);
  if (event.type === 'file.done') console.log('done:', event.data.file.name);
});
}).listen(8080);
```

The Standard Webhooks libraries verify the same headers: `pip install standardwebhooks`,
`npm install standardwebhooks`, and others for most languages.

> **Tip:**
> Serve the receiver behind your usual HTTPS proxy or load balancer: the URL you register must be
> `https://`. `POST /webhooks/{id}/test` checks the whole path, signature included.

## Rotate the secret

```bash
curl -s -X POST "$API/webhooks/$WEBHOOK/rotate-secret" -H "$AUTH"
```

The answer carries the new `secret`, shown only there, and `previousSecretExpiresAt`. Until then
every delivery is signed with both secrets, so:

1. Rotate, and give your server the new secret beside the old one.
2. Your server accepts a delivery when either secret's signature matches (the receivers above
   already compare every entry of `webhook-signature`).
3. Once the new secret is in place everywhere — within 24 hours — drop the old one.

## Retries

A delivery is made when your server answers with a `2xx` status within **10 seconds** of the
attempt starting: connecting, TLS and the status line all count, and the body you send back is not
waited for. Anything else — another status, a redirect (redirects are not followed), a timeout, a
refused connection — is tried again:

| Attempt | After the previous one |
|---|---|
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 and on | every hour |

A delivery still failing **24 hours** after its event is given up, and shows `status: "failed"` in
`GET /webhooks/{id}/deliveries?status=failed`. Each delivery there has `lastStatus` (your server's
last HTTP status, or null when it could not be reached) and `lastError`. Once your server is fixed,
`POST /webhooks/{id}/deliveries/{eventId}/retry` sends one again, with another 24 hours of retries;
deliveries are kept 7 days. After a longer outage, catch up by listing your projects' files.

Deliveries to one webhook go one at a time, and a few per webhook in each round: when your server
does not answer, the rest of its deliveries wait for the next round rather than each waiting out
its own timeout. Other accounts' webhooks are not slowed by yours, nor yours by theirs.

So that retries do no harm:

- **Answer fast**, then do the work: queue the event and return `204`.
- **Drop repeats** by `id`: an attempt your server handled but answered too slowly is sent again.
- **Do not depend on order.**

## Limits

| | |
|---|---|
| Webhooks | 10 per account, one per URL. |
| URL | `https://`, at most 2048 characters, no user name or password, a public address. |
| Answer | A `2xx` status within 10 seconds of the attempt starting. |
| Retries | For 24 hours after the event, or after `POST …/retry`. |
| Previous secret | Signs beside the new one for 24 hours after a rotation. |
| Delivery history | 7 days. |
