Webhooks
Slicer API webhooks — events about uploads, montage, export and publishing, the HMAC-SHA256 signature and verifying it in Node.js and Python, retries.
On this page
A webhook is an address on your server that Slicer calls on its own when something happens: an upload is ready, a montage is finished, a video is published. It's faster and cheaper than polling: no need to ask for the status every 5 seconds, and deliveries don't count against your rate limit.
Add a webhook
Requires the webhooks scope. The address must be a public https URL; up to 5 webhooks per account.
curl https://api.slicer.media/v1/webhooks \
-H "Authorization: Bearer $SLICER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/slicer/webhook","events":["project.succeeded","project.failed","export.succeeded"],"description":"Team chat notifications"}'{
"ok": true,
"data": {
"webhook": {
"id": "5f8e2a10-6c3b-4d7e-9f21-0a1b2c3d4e5f",
"url": "https://example.com/slicer/webhook",
"events": ["project.succeeded", "project.failed", "export.succeeded"],
"description": "Team chat notifications",
"enabled": true,
"disabledReason": null,
"failures": 0,
"lastDeliveryAt": null,
"createdAt": "2026-10-16T09:30:00.000Z"
},
"secret": "whsec_…"
}
}secret is the signing key. It's shown once: store it in an environment variable on your server, for example
SLICER_WEBHOOK_SECRET. You can also add webhooks in the app: Profile → Developers → Webhooks.
Events
| Event | When it arrives | data |
|---|---|---|
upload.ready | an upload was checked and is ready for a montage (including URL imports) | the upload |
upload.failed | the file was rejected | the upload with the reason in error |
project.succeeded | a montage, rebuild or retry finished | the project |
project.failed | a run failed; the token reservation was returned | the project with error |
project.cancelled | a run was stopped | the project |
project.version_ready | video versions are waiting for a choice — arrives after project.succeeded, only if a montage produced several versions. A montage now produces one, so this event doesn't come: wait for project.succeeded | the project with versionChoice |
export.succeeded | export is done, the file is updated | the project |
export.failed | export failed, the previous video is kept | the project with error |
publication.published | the video was published to a social network | the publication with url and projectId |
publication.failed | publishing failed | the publication with errorCode and projectId |
ping | a webhook check (POST /v1/webhooks/{id}/test) — sent immediately, without retries | webhookId and message |
The list of events may grow (Versioning): skip unknown types and still answer 2xx.
Request format
Slicer sends a POST with a JSON body and these headers:
POST /slicer/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Slicer-Webhooks/1.0 (+https://docs.slicer.media/api/webhooks/)
Slicer-Signature: t=1792152060,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Slicer-Event: project.succeeded
Slicer-Delivery: 48213
{
"id": "evt_9a3c1f0e7b2d4c58a1e6f03b7d9c2e41",
"type": "project.succeeded",
"createdAt": "2026-10-16T09:41:00.000Z",
"data": { "id": 318, "title": "A reel in 3 minutes", "status": "succeeded", "appUrl": "https://app.slicer.media/projects/318" }
}id— a unique event id (evt_…); use it to drop duplicates.type— the event type; it's also in theSlicer-Eventheader.data— an object in the same shape the API returns (GET /v1/projects/{id},GET /v1/uploads/{id}or a publication).Slicer-Delivery— the delivery number: it makes the delivery easy to find inGET /v1/webhooks/{id}/deliveries.
Verifying the signature
The signature proves the request came from Slicer and wasn't modified on the way. The Slicer-Signature header contains
the send time t (Unix seconds) and the signature v1 — a hex HMAC-SHA256 of the string <t>.<request body> keyed with
secret.
How to verify:
- Take the raw request body — the bytes as they arrived, before JSON parsing. After parsing and re-serializing, the signature won't match.
- Compute HMAC-SHA256 over
t, a dot and the body with your secret. - Compare it with
v1in constant time (timingSafeEqual,hmac.compare_digest), not with a plain==. - Reject the request if
tdiffers from the current time by more than 5 minutes — this protects against replaying an intercepted request.
// Node.js 20+: npm install express
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const TOLERANCE_SEC = 5 * 60;
/** true if the signature is valid and the request is no older than 5 minutes. */
export function verifySlicerSignature(rawBody, header, secret, nowMs = Date.now()) {
if (typeof header !== 'string') return false;
const pairs = header.split(',').map((part) => part.trim().split('='));
const timestamp = pairs.find(([key]) => key === 't')?.[1];
const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => value);
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
if (Math.abs(nowMs / 1000 - Number(timestamp)) > TOLERANCE_SEC) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((value) => {
const received = Buffer.from(value, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
const app = express();
const seen = new Set(); // in production — a database or Redis
// express.raw: the body arrives as a Buffer, exactly as Slicer signed it
app.post('/slicer/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySlicerSignature(req.body, req.get('Slicer-Signature'), process.env.SLICER_WEBHOOK_SECRET)) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(204); // answer fast, process afterwards
if (seen.has(event.id)) return;
seen.add(event.id);
setImmediate(() => {
if (event.type === 'project.succeeded') console.log(`Video ready: ${event.data.title} — ${event.data.appUrl}`);
});
});
app.listen(3000);# pip install flask
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
TOLERANCE_SEC = 5 * 60
def verify_slicer_signature(raw_body, header, secret, now=None):
"""True if the signature is valid and the request is no older than 5 minutes."""
if not header:
return False
pairs = [part.strip().split("=", 1) for part in header.split(",") if "=" in part]
timestamp = next((value for key, value in pairs if key == "t"), None)
signatures = [value for key, value in pairs if key == "v1"]
if timestamp is None or not timestamp.isdigit() or not signatures:
return False
if abs((now or time.time()) - int(timestamp)) > TOLERANCE_SEC:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
app = Flask(__name__)
seen = set() # in production — a database or Redis
@app.post("/slicer/webhook")
def slicer_webhook():
raw = request.get_data() # the raw body, before JSON parsing
if not verify_slicer_signature(raw, request.headers.get("Slicer-Signature"), os.environ["SLICER_WEBHOOK_SECRET"]):
abort(400)
event = json.loads(raw)
if event["id"] not in seen:
seen.add(event["id"])
if event["type"] == "project.succeeded":
print(f"Video ready: {event['data']['title']} — {event['data']['appUrl']}")
return "", 204Delivery and retries
- Success — a
2xxresponse within 10 seconds. Answer right away and do long work after responding or in a queue. - Retries — on any other response, a timeout or a connection error: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours — up to 7 attempts in total.
- Redirects are not followed. A
3xxresponse counts as a failed delivery — use the final address. - Order isn't guaranteed, and the same event may arrive twice (for example, if your response got lost). Drop
duplicates by
id, and when in doubt take the current state from the API. - Disabling — after 50 failed delivery attempts in a row the webhook is turned off (
enabled: false, the reason is indisabledReason), and your account email gets a message. Fix the server and turn the webhook back on:PATCH /v1/webhooks/{id}with{"enabled": true}. - The address is checked when the webhook is created and before every delivery: if it stops pointing to a public address, nothing is delivered.
Testing and the delivery log
# send a test ping event
curl -X POST https://api.slicer.media/v1/webhooks/$WEBHOOK_ID/test -H "Authorization: Bearer $SLICER_TOKEN"
# the last 100 deliveries: status, response code, attempts
curl https://api.slicer.media/v1/webhooks/$WEBHOOK_ID/deliveries -H "Authorization: Bearer $SLICER_TOKEN"Change the address, events or description with PATCH /v1/webhooks/{id}, delete with DELETE. All endpoints are in the
reference.