Skip to content

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#

  1. Add the endpoint

    Open Webhooks in the console, add your public HTTPS URL and choose the events. No events selected means all of them.

  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.

  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.

Events#

EventWhendata
render.completedA render finished.jobId, projectId, url, posterUrl, format, durationSec, width, height
render.failedA render failed.jobId, projectId, error
credits.lowYour Telegram bot's work took the balance under 200 credits (at most once a day).appId, remaining, source
credits.exhaustedThe bot refused work because the balance was short (at most every 6 hours).appId, remaining, source
app.suspendedWe suspended the app; its keys and bot stop.appId, reason
telegram.end_user.limit_reachedSomebody hit your per-person daily cap (once per person per day).appId, endUserId, telegramUserId, limit
telegram.end_user.first_seenSomebody used the bot for the first time.appId, endUserId, telegramUserId, username, chatType
telegram.end_user.blockedYou 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:

HeaderValue
Vidmoat-Event-IdThe event id, the same on every retry.
Vidmoat-Event-TypeFor example render.completed.
Vidmoat-Signaturet=<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.

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);

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.