Skip to content

DocsDevelopers

Webhook signatures

Event payloads, retries and verifying signatures in Node and Python.

Webhooks are signed with HMAC-SHA256 using your endpoint's secret. Verify every request before trusting it. Setting up endpoints is covered in Webhooks.

The request#

Each event is a POST with a JSON body and four headers. The same event id is sent on every retry, so deduplicate on it.

Headers
X-Loudscene-Event: render.completed
X-Loudscene-Delivery: 7d3a9c1e-4b2f-4e8a-9c6d-1f2e3a4b5c6d
X-Loudscene-Timestamp: 1790000000
X-Loudscene-Signature: t=1790000000,v1=5f2a…(64 hex characters)
Body
{
  "id": "evt_5b0c1f0e2d3a4b5c6d7e8f9012345678",
  "type": "render.completed",
  "created": 1790000000,
  "workspace_id": "0b1c2d3e-4f50-4617-8899-aabbccddeeff",
  "data": {
    "render": {
      "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "video_id": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
      "version_id": "6c5b4a39-2817-4f6e-8d5c-4b3a29180f7e",
      "format": "9:16",
      "resolution": 1080,
      "fps": 30,
      "watermark": false,
      "status": "succeeded",
      "progress": 1,
      "storage_path": "<workspace>/<video>/<render>/video.mp4",
      "poster_path": "<workspace>/<video>/<render>/poster.jpg",
      "duration_sec": 18.4,
      "bytes": 4839210,
      "error": null,
      "url": "https://…/video.mp4",
      "poster_url": "https://…/poster.jpg"
    }
  }
}
Eventdata carries
video.generatedvideo: id, title, status, story_id, current_version_id, default_format, duration_sec
render.progressrender
render.completedrender
render.failedrender
comment.createdcomment: id, video_id, parent_id, review, t (seconds), body, author_name, is_guest, created_at

Verifying the signature#

  1. Read t and v1 from X-Loudscene-Signature.
  2. Compute HMAC-SHA256 of <t>.<raw body> with your secret, as hex.
  3. Compare it with v1 in constant time, and reject timestamps more than 5 minutes old.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, rawBody, header, toleranceSec = 300) {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // too old: maybe a replay
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

const app = express();

// Verify against the raw body: re-serialised JSON would not match the signature.
app.post("/webhooks/loudscene", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const ok = verify(process.env.LOUDSCENE_WEBHOOK_SECRET, raw, req.get("x-loudscene-signature"));
  if (!ok) return res.status(400).send("Invalid signature");

  const event = JSON.parse(raw);
  // Retries resend the same event.id: skip ids you have already handled.
  if (event.type === "render.completed") {
    console.log("MP4 ready:", event.data.render.url);
  }
  res.sendStatus(204);
});

app.listen(3000);

Delivery and retries#

  • Answer with any 2xx within 8 seconds. Do slow work after answering.
  • Failed deliveries are retried after 30 s, 2 min, 10 min, 30 min, 1 h, 3 h and 6 h (each give or take 10%).
  • Answer 410 Gone to stop retries of that delivery. Other 4xx answers are retried, since they are often a deploy in progress.
  • An endpoint that points at a private address is not retried.
  • Send test in Settings posts a signed ping event with the same headers, so a receiver that passes the test works for real events.