# Webhooks

Get told when a format or video you started through the API is ready or failed, and check that each message came from 10xvid.

## Add a webhook

Open the [API keys](/app/api-keys/webhooks) page in the app, go to **Webhooks**, and give the address of your server. It must start with `https://`. You get a signing secret beside it. **Send test** sends a `ping` at once and shows what your server answered.

Only what you started through the API tells your webhook. What you make in the app does not.

## Events

| Event | When it is sent |
|---|---|
| `format.ready` | The Format DNA is written and passed: videos can be made from it. |
| `format.failed` | The site could not be read, or the Format DNA could not be written. What it cost was given back. |
| `video.done` | A video finished. Its files are in the message. |
| `video.failed` | A video could not be finished. Its price was given back. |
| `image.done`, `image.failed` | The same for image ads. |
| `ping` | The **Send test** button. |

## The message

A `POST` with a JSON body. `data.object` is the format or the video, the same as the API answers for it.

```json
{
  "id": "evtmgn5c1a2b3c",
  "object": "event",
  "type": "video.done",
  "created": "2026-10-13T10:02:18.120Z",
  "data": { "object": { "id": "MV40XXRN514AA9", "object": "video", "status": "done", "files": [] } }
}
```

Three headers come with it:

| Header | What it holds |
|---|---|
| `X-10xvid-Signature` | `t=<seconds>,v1=<signature>` |
| `X-10xvid-Event` | The event, such as `video.done` |
| `X-10xvid-Delivery` | The message's id, the same on every try |

## Check the signature

The signature is the HMAC SHA-256 of the seconds, a full stop and the raw body, under your webhook's secret, as Stripe signs its own. Check it, and turn away a message more than five minutes old.

JavaScript:

```js
import crypto from 'node:crypto';

export function isFrom10xvid(body, header, secret) {
  const [, t, v1] = /t=(\d+),v1=([a-f0-9]{64})/.exec(header ?? '') ?? [];
  if (!v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const want = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(want));
}
```

Python:

```python
import hashlib
import hmac
import time

def is_from_10xvid(body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, want)
```

## Answer, and tries again

Answer with any `2xx`. Anything else, or no answer within 10 seconds, and the message is sent again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up. The last messages and what your server answered are listed under the webhook. Use `X-10xvid-Delivery` to notice a message you already handled.

## A new secret

**New secret** signs every message from then on with a new one. Put it on your server before the next message arrives.
