Platform

Webhooks

Add a webhook_url when you create a job, and we POST the finished job to it the moment it succeeds or fails. Every delivery is signed with HMAC-SHA256, so you can check it came from clipsdk.

Add a webhook

Set webhook_url on POST /v1/clips. It has to be an https:// URL of up to 4,096 characters that accepts POST requests.

POST /v1/clips body
{
  "video_url": "https://cdn.example.com/off-script-112.mp4",
  "webhook_url": "https://example.com/hooks/clipsdk"
}

The create call still waits up to 60 seconds as usual. Add ?wait=0 to get the job back right away and leave the result to the webhook.

Events and payload

job.succeeded
The job finished. job.result has the clips.
job.failed
The job failed. job.error has the code and message, listed under Job failures.

A job sends one of these when it finishes. The body holds the event and the full job, exactly as GET /v1/jobs/{id} returns it:

POSThttps://example.com/hooks/clipsdk
Content-Type:
application/json
User-Agent:
clipsdk-webhooks/1
Clipsdk-Signature:
t=1793006268,v1=0ab450fc255caef1d078168746e548a9e9112bc20796db1d26c1504eaa6ae7a8
Clipsdk-Job-Id:
job_q8w2e4r6t1y3u5i7o9p0
Clipsdk-Attempt:
1
Idempotency-Key:
job_q8w2e4r6t1y3u5i7o9p0:succeeded
{
  "event": "job.succeeded",
  "job": {
    "id": "job_q8w2e4r6t1y3u5i7o9p0",
    "op": "clips",
    "status": "succeeded",
    "stage": null,
    "created_at": "2026-10-26T09:14:02Z",
    "finished_at": "2026-10-26T09:17:48Z",
    "result": {
      "source": {
        "duration_s": 3486.2,
        "language": "en",
        "speakers": null
      },
      "clips": [
        {
          "rank": 1,
          "score": 96,
          "title": "We were three weeks from shutting down",
          "reason": "Opens on the stakes, tells the whole story and lands the turn",
          "start_s": 1713.4,
          "end_s": 1755.9,
          "duration_s": 42.5,
          "transcript": "Honestly? We were three weeks from shutting the whole thing down. Payroll was due Friday, the bank had said no twice, and then one cold email changed everything.",
          "post_caption": "Three weeks from shutting down. Then one cold email changed everything. #startups #founders",
          "video": "https://api.clipsdk.com/f/out/job_q8w2e4r6t1y3u5i7o9p0/clip-1.mp4",
          "poster": "https://api.clipsdk.com/f/out/job_q8w2e4r6t1y3u5i7o9p0/clip-1.jpg",
          "srt": "https://api.clipsdk.com/f/out/job_q8w2e4r6t1y3u5i7o9p0/clip-1.srt",
          "vtt": "https://api.clipsdk.com/f/out/job_q8w2e4r6t1y3u5i7o9p0/clip-1.vtt"
        }
      ]
    },
    "usage": {
      "source_minutes": 58.1,
      "clips": 6,
      "output_seconds": 240
    },
    "error": null,
    "poll_url": "https://api.clipsdk.com/v1/jobs/job_q8w2e4r6t1y3u5i7o9p0"
  }
}

Headers

Clipsdk-Signature
The timestamp and signature. See Verify the signature.
Clipsdk-Job-Id
The job's id, the same as job.id.
Clipsdk-Attempt
Which attempt this is, from 1 to 3.
Idempotency-Key
The job id and the outcome, like job_q8w2e4r6t1y3u5i7o9p0:succeeded. It's the same on every attempt.
User-Agent
Always clipsdk-webhooks/1. Anyone can send that, so trust the signature, not this.
Content-Type
application/json

Verify the signature

Check every delivery before you act on it. Clipsdk-Signature carries a timestamp and an HMAC of that timestamp and the raw body:

Clipsdk-Signature

Header value

t=1793006268,v1=0ab450fc255caef1d078168746e548a9e9112bc20796db1d26c1504eaa6ae7a8

  • t is when we sent this attempt, in Unix seconds. Every attempt gets a new one.
  • v1 is the HMAC-SHA256 of the signed string below, in hex.

Signed string

1793006268.{"event":"job.succeeded","job":{"id":"jo...

t, a period, then the raw request body, byte for byte.

Key

whsec_<32 letters and digits>

Your whole signing secret, whsec_ included. Use it as is: don't strip the prefix or decode it.
  1. 1Read the raw request body, before any JSON parsing. Parsed and re-serialized JSON won't match.
  2. 2Split the header on commas into t and v1.
  3. 3Reject the delivery if t is more than 300 seconds away from your clock. That stops replays of old deliveries.
  4. 4Compute the HMAC-SHA256 of t, a period and the raw body, keyed with your whole secret, as hex.
  5. 5Compare it with v1 in constant time, and accept only a match.
import crypto from "node:crypto";
import express from "express";

const TOLERANCE_SECONDS = 300;

function verifySignature(rawBody, header, secret) {
  const parts = header.split(",").map((part) => part.trim().split("="));
  const timestamp = parts.find(([key]) => key === "t")?.[1];
  const signatures = parts
    .filter(([key]) => key === "v1")
    .map(([, value]) => value);
  if (!timestamp || signatures.length === 0) return false;

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + ".")
    .update(rawBody)
    .digest();
  return signatures.some((signature) => {
    const given = Buffer.from(signature, "hex");
    if (given.length !== expected.length) return false;
    return crypto.timingSafeEqual(given, expected);
  });
}

const app = express();

// express.raw keeps the exact bytes we sent, which is what the signature covers
app.post("/hooks/clipsdk", express.raw({ type: "*/*" }), (req, res) => {
  const header = req.get("Clipsdk-Signature") ?? "";
  if (!verifySignature(req.body, header, process.env.CLIPSDK_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const { event, job } = JSON.parse(req.body);
  console.log(event, job.id, job.status);
  res.sendStatus(200);
});

app.listen(3000);

When the check fails, reply 400 and ignore the body.

Replies and retries

Reply with any 2xx status within 15 seconds and the delivery is done. Anything else is a failed attempt: another status, a redirect (we don't follow them), a network error or no reply in time.

We try 3 times: right away, 2 s after the first failure and 8 s after the second. Every attempt has a fresh t and signature.

Each attempt waits up to 15 seconds for your reply. When your endpoint answers with an error, the attempts go out at 0, 2 and 10 s. When it never answers, they start at 0, 17 and 40 s.

Numbered bars are attempts, each waiting up to 15 s for your reply. After the last one we stop: even when every attempt times out, that's about 55 s after the job finished.

If your endpoint was down for all of them, GET the job to catch up. It stays readable after the webhook gives up.

Reply first, work later

Store the event and return 200 right away, then process it in the background. Downloading clips inside the request can easily take longer than 15 seconds.

Duplicates

The same event can arrive more than once, for example when you handled it but your reply came too late. Idempotency-Key is the same on every attempt, so store the keys you've handled and skip repeats.

Signing secret

Each API key has its own signing secret: whsec_ and 32 letters and digits. It's shown once, when you create the key. A delivery is signed with the secret of the key that created the job.

Keep it on your server in an environment variable, like CLIPSDK_WEBHOOK_SECRET in the examples, never in your code.

Test locally

Your endpoint must be reachable over HTTPS from the internet. To test on your own machine, open a tunnel and use its HTTPS URL as webhook_url:

Terminal
# Cloudflare Tunnel, no account needed
cloudflared tunnel --url http://localhost:3000

# or ngrok
ngrok http 3000

A short test video makes the round trip quick.