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.
{
"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.resulthas the clips. - job.failed
- The job failed.
job.errorhas 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:
- 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
1to3. 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-Typeapplication/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:
Header value
t=1793006268,v1=0ab450fc255caef1d078168746e548a9e9112bc20796db1d26c1504eaa6ae7a8
tis when we sent this attempt, in Unix seconds. Every attempt gets a new one.v1is 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>
whsec_ included. Use it as is: don't strip the prefix or decode it.- 1Read the raw request body, before any JSON parsing. Parsed and re-serialized JSON won't match.
- 2Split the header on commas into
tandv1. - 3Reject the delivery if
tis more than 300 seconds away from your clock. That stops replays of old deliveries. - 4Compute the HMAC-SHA256 of
t, a period and the raw body, keyed with your whole secret, as hex. - 5Compare it with
v1in 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.
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
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:
# Cloudflare Tunnel, no account needed
cloudflared tunnel --url http://localhost:3000
# or ngrok
ngrok http 3000A short test video makes the round trip quick.