Docs

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.

Manage your webhook subscriptions at /settings/webhooks.

Headers we send

Content-Typeapplication/json
User-AgentClipCabinet-Webhooks/1.0
X-ClipCabinet-Eventrecording.completed (or .failed, or comment.added)
X-ClipCabinet-Deliverydel_xxxxxxxxxxxxxxxx (idempotency key per attempt)
X-ClipCabinet-Signaturesha256=<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.

Node.js (Express)
// 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");
});
Python (Flask)
# 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"
  }
}