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.
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){
"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"
}
}
}| Event | data carries |
|---|---|
video.generated | video: id, title, status, story_id, current_version_id, default_format, duration_sec |
render.progress | render |
render.completed | render |
render.failed | render |
comment.created | comment: id, video_id, parent_id, review, t (seconds), body, author_name, is_guest, created_at |
Verifying the signature#
- Read
tandv1fromX-Loudscene-Signature. - Compute HMAC-SHA256 of
<t>.<raw body>with your secret, as hex. - Compare it with
v1in 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 Goneto 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
pingevent with the same headers, so a receiver that passes the test works for real events.