# Webhooks

> Slicer API webhooks — events about uploads, montage, export and publishing, the HMAC-SHA256 signature and verifying it in Node.js and Python, retries.

Source: https://docs.slicer.media/en/api/webhooks/

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](https://docs.slicer.media/en/api/rate-limits/).

## Add a webhook

Requires the `webhooks` scope. The address must be a public `https` URL; up to 5 webhooks per account.

Terminal:

```bash
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"}'
```

Response:

```json
{
  "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](https://docs.slicer.media/en/api/versioning/)): skip unknown types and still answer `2xx`.

## Request format

Slicer sends a `POST` with a JSON body and these headers:

Request to your server:

```http
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 the `Slicer-Event` header.
-   `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 in `GET /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:

1.  Take the **raw request body** — the bytes as they arrived, before JSON parsing. After parsing and re-serializing, the signature won't match.
2.  Compute HMAC-SHA256 over `t`, a dot and the body with your secret.
3.  Compare it with `v1` **in constant time** (`timingSafeEqual`, `hmac.compare_digest`), not with a plain `==`.
4.  Reject the request if `t` differs from the current time by more than 5 minutes — this protects against replaying an intercepted request.

**JavaScript**

webhook.mjs:

```js
// 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);
```

**Python**

webhook.py:

```python
# 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 "", 204
```

## Delivery and retries

-   **Success** — a `2xx` response 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 `3xx` response 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 in `disabledReason`), 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

Terminal:

```bash
# 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](https://docs.slicer.media/en/api/reference/webhooks/).

> **Tip.** To test on your own computer you need a public `https` address: Slicer doesn't accept `localhost` or internal networks. A tunnel to your local server works (for example, `cloudflared` or `ngrok`).

## What's next

[Notifications via webhookRecipe: a chat message when the video is ready.](https://docs.slicer.media/en/api/recipes/webhook-notify/)[Reference: webhooksCreate, update, test and the delivery log.](https://docs.slicer.media/en/api/reference/webhooks/)[SecurityHow Slicer protects event delivery.](https://docs.slicer.media/en/security/)
