# Webhooks

> Signed HTTP notifications when a render finishes, credits run low or something happens on your Telegram bot. How to receive them, verify them and stay correct when one goes missing.

Instead of polling a render until it finishes, register an HTTPS endpoint and Vidmoat will POST a signed event to it.

## Create a destination

### Step 1: Add the endpoint

Open [Webhooks in the console](https://developer.vidmoat.com/developer/webhooks), add your public HTTPS URL and choose the events. No events selected means all of them.

### Step 2: Save the signing secret

It starts with `whsec_` and is shown when you create the endpoint or roll the secret. Store it with your other server secrets.

### Step 3: Send a test

"Send test" delivers one `ping` event and shows the status code and body your receiver returned. The delivery log keeps every attempt.

> **Note.** Webhook destinations are managed from the signed-in console. There is no public `/v1/webhooks` endpoint yet, and the reserved `webhooks.manage` scope does not turn an API key into a console session. AI generation jobs do not send events: poll the job.

## Events

| Event | When | `data` |
| --- | --- | --- |
| `render.completed` | A render finished. | `jobId`, `projectId`, `url`, `posterUrl`, `format`, `durationSec`, `width`, `height` |
| `render.failed` | A render failed. | `jobId`, `projectId`, `error` |
| `credits.low` | Your Telegram bot's work took the balance under 200 credits (at most once a day). | `appId`, `remaining`, `source` |
| `credits.exhausted` | The bot refused work because the balance was short (at most every 6 hours). | `appId`, `remaining`, `source` |
| `app.suspended` | We suspended the app; its keys and bot stop. | `appId`, `reason` |
| `telegram.end_user.limit_reached` | Somebody hit your per-person daily cap (once per person per day). | `appId`, `endUserId`, `telegramUserId`, `limit` |
| `telegram.end_user.first_seen` | Somebody used the bot for the first time. | `appId`, `endUserId`, `telegramUserId`, `username`, `chatType` |
| `telegram.end_user.blocked` | You blocked somebody, in the console or with the API. | `appId`, `endUserId`, `telegramUserId`, `username`, `via` |

Every delivery has the same envelope:

```json
{
  "id": "evt_3f9a0c1d2e4b5a6978c0d1e2",
  "type": "render.completed",
  "createdAt": "2026-10-03T12:01:10.000Z",
  "data": {
    "jobId": "clx9abc123",
    "projectId": "cm1abc",
    "url": "https://api.vidmoat.com/…/clx9abc123.mp4",
    "posterUrl": "https://api.vidmoat.com/…/clx9abc123.jpg",
    "format": "mp4",
    "durationSec": 12.5,
    "width": 1080,
    "height": 1920
  }
}
```

New event types and new `data` fields can appear. Ignore what you do not recognise.

## Verify every request

Each request carries three headers:

| Header | Value |
| --- | --- |
| `Vidmoat-Event-Id` | The event id, the same on every retry. |
| `Vidmoat-Event-Type` | For example `render.completed`. |
| `Vidmoat-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |

The MAC is HMAC-SHA256 with your signing secret over the timestamp, a full stop, and the **exact raw request body**. Compute it before parsing the JSON, compare in constant time, and reject a timestamp more than five minutes from your clock to stop replays.

```js server.mjs
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.VIDMOAT_WEBHOOK_SECRET; // whsec_...

export function verify(rawBody, header, secret = SECRET, toleranceSec = 300) {
  const t = /(?:^|,)t=(\d+)(?:,|$)/.exec(header ?? '')?.[1];
  const mac = /(?:^|,)v1=([a-f0-9]{64})(?:,|$)/.exec(header ?? '')?.[1];
  if (!t || !mac) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = createHmac('sha256', secret).update(t + '.').update(rawBody).digest();
  const received = Buffer.from(mac, 'hex');
  return received.length === expected.length && timingSafeEqual(received, expected);
}

const app = express();
// The raw bytes, not parsed JSON: re-serialising changes the bytes and breaks the MAC.
app.post('/webhooks/vidmoat', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('Vidmoat-Signature'))) return res.status(400).end();
  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, queue the work, answer fast.
  res.status(200).end();
});
app.listen(3000);
```

```python app.py
import hashlib, hmac, json, os, re, time
from flask import Flask, request, abort

SECRET = os.environ["VIDMOAT_WEBHOOK_SECRET"]  # whsec_...
app = Flask(__name__)

def verify(raw_body: bytes, header: str, secret: str = SECRET, tolerance: int = 300) -> bool:
    t = re.search(r"(?:^|,)t=(\d+)(?:,|$)", header or "")
    mac = re.search(r"(?:^|,)v1=([a-f0-9]{64})(?:,|$)", header or "")
    if not t or not mac:
        return False
    if abs(time.time() - int(t.group(1))) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.group(1).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, mac.group(1))

@app.post("/webhooks/vidmoat")
def receive():
    raw = request.get_data()  # the exact bytes Vidmoat signed
    if not verify(raw, request.headers.get("Vidmoat-Signature", "")):
        abort(400)
    event = json.loads(raw)
    # Deduplicate on event["id"], queue the work, answer fast.
    return "", 200
```

> **Important: Use the raw body.** Most frameworks parse JSON before your handler runs. Parsing and re-serialising changes whitespace and key order, and the signature will never match. Read the raw bytes (as above), verify, then parse.

## Answer quickly, and deduplicate

Return any `2xx` as soon as the event is safely stored, then do the work. Delivery is at least once: store `id` and skip an event you have already handled.

## Retries

- Each attempt waits up to 10 seconds for your answer.
- A timeout, a network error, a `5xx`, a `408`, a `429` or a redirect (redirects are not followed) is retried, up to **5 attempts** in all, waiting 10, 20, 40 and then 80 seconds between them.
- Any other `4xx` is final: it means your receiver refused the event.
- After **20 failed deliveries in a row** the endpoint is disabled, and the console shows why. Fix the receiver, send a test, and enable it again.

## Keep a recovery path

A webhook can be lost to an outage on your side. Reconcile with a bounded poll of `GET /v1/renders/{id}`, or list recent jobs with `GET /v1/renders?projectId=…`. Do not queue another render because a notification has not arrived yet.

- [Open the webhook console](https://developer.vidmoat.com/developer/webhooks): Endpoints, delivery log, test sends and secret rotation.
- [Errors and retries](https://developer.vidmoat.com/developer/docs/errors): What to retry on the API side.

---

Source: https://developer.vidmoat.com/developer/docs/webhooks
Previous: [Build with Speech Engine](https://developer.vidmoat.com/developer/docs/speech-engine.md)
Next: [Video preview](https://developer.vidmoat.com/developer/docs/preview.md)
All documentation: https://developer.vidmoat.com/llms-full.txt
