Webhooks
ClipCabinet sends an HTTPS POST to your endpoint when a recording finishes processing, fails, or gets a new comment. Every delivery is signed with HMAC-SHA256 so you can verify it came from us.
Headers we send
| Content-Type | application/json |
| User-Agent | ClipCabinet-Webhooks/1.0 |
| X-ClipCabinet-Event | recording.completed (or .failed, or comment.added) |
| X-ClipCabinet-Delivery | del_xxxxxxxxxxxxxxxx (idempotency key per attempt) |
| X-ClipCabinet-Signature | sha256=<hex digest of HMAC-SHA256(secret, raw body)> |
Verifying the signature
Compute HMAC-SHA256 over the raw request body using the signing secret you got when you created the webhook (the whsec_... value). Compare against the value in the X-ClipCabinet-Signature header using a constant-time compare.
// Verify a ClipCabinet webhook signature in Node.js
import crypto from "node:crypto";
const SECRET = process.env.CLIPCABINET_WEBHOOK_SECRET; // whsec_...
export function verifyClipCabinetWebhook(
rawBody: string,
signatureHeader: string,
): boolean {
if (!signatureHeader.startsWith("sha256=")) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected),
);
}
// Express example
app.post("/hooks/clipcabinet", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.header("X-ClipCabinet-Signature") ?? "";
if (!verifyClipCabinetWebhook(req.body.toString("utf8"), sig)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event
res.status(200).send("ok");
});# Verify a ClipCabinet webhook signature in Python
import hmac
import hashlib
import os
SECRET = os.environ["CLIPCABINET_WEBHOOK_SECRET"] # whsec_...
def verify_clipcabinet_webhook(raw_body: bytes, signature_header: str) -> bool:
if not signature_header.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(
SECRET.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature_header, expected)
# Flask example
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/hooks/clipcabinet")
def receive_clipcabinet_webhook():
sig = request.headers.get("X-ClipCabinet-Signature", "")
if not verify_clipcabinet_webhook(request.get_data(), sig):
abort(401, "invalid signature")
event = request.get_json(force=True)
# ... handle event
return ("ok", 200)Retries
Any non-2xx response is treated as a failure. We retry on an exponential backoff: 30 seconds, then 1 minute, 5, 15, 30, 1 hour, and 6 hours. After roughly 8 hours of failures we mark the delivery failed and stop retrying. You can manually retry from the deliveries drawer in the dashboard.
Idempotency
Every attempt of a delivery carries the same X-ClipCabinet-Delivery header. Use it as an idempotency key on your side so retries don’t double-process. The same deliveryId appears in the JSON body.
Payload schemas
recording.completed{
"event": "recording.completed",
"deliveryId": "del_2YwK3pQ8r5n6mZ4a",
"occurredAt": "2026-04-30T22:00:14.512Z",
"data": {
"recordingId": "cmokuzps0000355mi4lufpkqa",
"userId": "user_2yPcKa4qR8w3vXk5",
"title": "Walking through the deploy pipeline",
"kind": "video",
"source": "extension",
"sourceUrl": "https://github.com/yourorg/yourapp/actions/runs/1234567",
"sourceDomain": "github.com",
"durationSec": 412,
"tags": ["ops", "deploy"],
"starred": false,
"summary": "Stepped through the GitHub Actions run that failed at the migrate step...",
"counts": { "transcriptTurns": 84, "frames": 18, "artifacts": 6 },
"links": {
"appUrl": "https://clipcabinet.com/recordings/cmokuzps0000355mi4lufpkqa",
"shareUrl": null
},
"createdAt": "2026-04-30T21:53:22.108Z"
}
}
// Screenshot clips emit the same event shape with kind="screenshot".
// transcriptTurns will be 0; frames will be 1 (the screenshot itself).recording.failed{
"event": "recording.failed",
"deliveryId": "del_8aE2nVf6kPcXt9Wq",
"occurredAt": "2026-04-30T22:01:08.219Z",
"data": {
"recordingId": "cmokuzps0000355mi4lufpka",
"userId": "user_2yPcKa4qR8w3vXk5",
"title": null,
"kind": "video",
"errorMessage": "Transcribe step exceeded the 10-minute timeout",
"createdAt": "2026-04-30T21:53:22.108Z"
}
}comment.added{
"event": "comment.added",
"deliveryId": "del_M5pK2bN9wHfRa1Jx",
"occurredAt": "2026-04-30T22:04:51.831Z",
"data": {
"commentId": "cmt_3kLpQv8aWmZ7t",
"recordingId": "cmokuzps0000355mi4lufpkqa",
"recordingTitle": "Walking through the deploy pipeline",
"authorName": "Jamie",
"body": "What was the env var that was unset?",
"timestampMs": 184500,
"links": {
"appUrl": "https://clipcabinet.com/recordings/cmokuzps0000355mi4lufpkqa"
},
"createdAt": "2026-04-30T22:04:51.512Z"
}
}