Vodmill docs
v1.0.0
Get help
● Operate · Automation

Webhooks
hear about what finished, without polling.

A workspace can register https addresses that receive a signed POST when a source is ready, a clip is rendered, a render fails, a post goes out or fails, or someone joins the team.

HMAC-SHA256x-studio-signature6 events3 tries

01Add a webhook

Webhooks follow the same switches as the API: the operator's "Public API and webhooks" setting and a plan with "REST API and webhooks" (API v1).

  1. Open Developers in the app (workspace owners).
  2. Add webhook Enter a public https address and pick the events. Zapier and Make give you such an address in their "Webhooks" trigger.
  3. Copy the signing secret It is shown once. Check every delivery with it.
  4. Test The "Test" button sends a ping. "Deliveries" shows the latest 50 with their result.

A workspace can have 10 webhooks. A webhook can be paused and resumed.

02Events

WEBHOOK_EVENTS in src/server/platform.ts:

EventWhendata
source.readyThe analysis of a source finished{ source }
clip.renderedA render finished{ render, clip }; the render's files carry signed links that work for one hour
render.failedA render failed (its credits were returned){ render, clip }
publication.postedA post went out{ publication }
publication.failedA post failed{ publication }
member.joinedSomeone accepted an invite to the workspace{ member: { email }, workspace: { id, name } }

The objects are the same as in the API. The app's Developers page lists the same six events.

03What a delivery looks like

POST /your/endpoint
content-type: application/json
x-studio-event: clip.rendered
x-studio-signature: t=1794301200,v1=5f2c…

{
  "id": "evt_…",
  "type": "clip.rendered",
  "created": "2026-11-10T09:00:00.000Z",
  "workspace": "…",
  "data": { "render": { … }, "clip": { … } }
}
  • Answer with a 2xx status within 10 seconds. Do slow work after you answered.
  • A delivery that gets no answer, times out or gets an answer that is not 2xx is tried again: up to 3 tries in all, the first at once, the second about 30 seconds later, the third about 5 minutes after that (WEBHOOK_TRIES and WEBHOOK_BACKOFF_MS in src/server/platform.ts). Every try is listed under "Deliveries".
  • Every try carries the same body with the same event id and a fresh signature made at the time of that try, so the five-minute check below also passes on a retry. Use the id to ignore an event you already handled.
  • After the third failed try the event is dropped. Waiting tries are also dropped when the webhook is paused, the API is switched off or the plan no longer includes it. After a longer outage of your receiver, catch up through the API (GET /renders, GET /sources).
  • The "Test" ping is sent once.
  • Addresses must be https and public: no localhost, no private networks, no user name or password in the address. Redirects are not followed.

04Verify the signature

The header is x-studio-signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of t + "." + raw body with the webhook's signing secret. Reject a timestamp older than five minutes. Always compute it over the raw body, before parsing the JSON.

Node.js

import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.WEBHOOK_SECRET;   // the signing secret shown once when the webhook was created

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!parts.v1 || !Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;           // older than 5 minutes: a replay
  const want = Buffer.from(createHmac("sha256", secret).update(t + "." + rawBody).digest("hex"));
  const got = Buffer.from(parts.v1);
  return want.length === got.length && timingSafeEqual(want, got);
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks).toString("utf8");
    if (!verify(raw, req.headers["x-studio-signature"], SECRET)) {
      res.writeHead(401).end("bad signature");
      return;
    }
    const event = JSON.parse(raw);
    console.log(req.headers["x-studio-event"], event.id, event.type);
    res.writeHead(200).end("ok");
  });
}).listen(3000);

This receiver was run against version 1.0.0's signing code: a correct signature answers 200, a wrong secret and a stale timestamp answer 401.

Any other language

signed  = t + "." + raw_body
expect  = hex( HMAC_SHA256( key = signing_secret, message = signed ) )
valid   = constant_time_equal(expect, v1)  AND  |now − t| ≤ 300 seconds

The webhook channel in Publishing signs its requests the same way, so one verifier serves both.

05If you renamed the product

The header prefix x-studio comes from header in src/shared/product.ts. If you change it there, the two header names change with it (White label).