# Notifications via webhook

> A Node.js recipe — a small server receives Slicer webhooks, verifies the signature and sends a Telegram message when a video is ready or published.

Source: https://docs.slicer.media/en/api/recipes/webhook-notify/

A montage takes a few minutes, and there's no need to wait for it in the terminal. This recipe runs a small server that receives Slicer [webhooks](https://docs.slicer.media/en/api/webhooks/) and writes to Telegram: "Video ready" with a link to the project, "Published" with a link to the video on the social network. Sending messages to Slack, email or your task tracker works the same way.

You will need: Node.js 20+, a server with a public `https` address (or a tunnel for testing), a token with the `webhooks` scope, a Telegram bot (its token comes from [@BotFather](https://t.me/BotFather)) and the id of the chat to write to.

## The server

notify.mjs:

```js
// npm install express
// SLICER_WEBHOOK_SECRET=whsec_… TELEGRAM_BOT_TOKEN=… TELEGRAM_CHAT_ID=… node notify.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const TOLERANCE_SEC = 5 * 60;

function verifySlicerSignature(rawBody, header, secret) {
  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(Date.now() / 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);
  });
}

async function telegram(text) {
  const response = await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ chat_id: process.env.TELEGRAM_CHAT_ID, text, disable_web_page_preview: true }),
  });
  if (!response.ok) console.error('Telegram responded', response.status);
}

function message(event) {
  const item = event.data ?? {};
  switch (event.type) {
    case 'project.succeeded':
      return `🎬 "${item.title}" is ready — ${Math.round(item.durationSec ?? 0)} s.\n${item.appUrl}`;
    case 'export.succeeded':
      return `📦 The export of "${item.title}" is ready.\n${item.appUrl}`;
    case 'publication.published':
      return `🚀 Published to ${item.platform}: ${item.url}`;
    case 'ping':
      return '✅ Slicer webhook connected';
    default:
      return null; // this bot doesn't need other events
  }
}

const app = express();
const seen = new Set(); // event ids: a repeated delivery won't send a second message

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); // Slicer waits no longer than 10 seconds — answer right away

  if (seen.has(event.id)) return;
  seen.add(event.id);
  const text = message(event);
  if (text) telegram(text).catch((error) => console.error(error));
});

app.listen(Number(process.env.PORT ?? 3000), () => console.log('Waiting for Slicer webhooks'));
```

## Setup

### 1. Run the server over https

Host `notify.mjs` on a server with a public `https` address. For testing on your own computer, a tunnel works:

Terminal:

```bash
node notify.mjs &
cloudflared tunnel --url http://localhost:3000
```

The tunnel prints an address like `https://….trycloudflare.com`.

### 2. Register the webhook

Terminal:

```bash
curl https://api.slicer.media/v1/webhooks \
  -H "Authorization: Bearer $SLICER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://notify.example.com/slicer/webhook","events":["project.succeeded","export.succeeded","publication.published"],"description":"Telegram"}' \
  | jq -r '.data.secret, .data.webhook.id'
```

The first line of the output is the `whsec_…` secret: pass it to the server as `SLICER_WEBHOOK_SECRET` and restart it. The second is the webhook id.

### 3. Test it

Terminal:

```bash
curl -X POST https://api.slicer.media/v1/webhooks/$WEBHOOK_ID/test -H "Authorization: Bearer $SLICER_TOKEN"
```

The chat gets "✅ Slicer webhook connected". If not, check the delivery log: `GET /v1/webhooks/{id}/deliveries` shows your server's response code.

## Fine points

-   **Raw body.** `express.raw` hands over the body as is — the signature is computed over exactly that. If your app already uses `express.json()` globally, register the webhook route before it.
-   **Retries.** If the server didn't answer within 10 seconds or answered with an error, Slicer delivers again later. The `seen` set prevents a second message; in production keep event ids in Redis or a database.
-   **Security.** Don't log the secret and don't reply with details to a bad signature. The `appUrl` link opens the project only after signing in to Slicer, so it's fine to send it to a team chat.

## What's next

[WebhooksAll events, the signature and the retry schedule.](https://docs.slicer.media/en/api/webhooks/)[A reel from a folderStarting a montage from a script.](https://docs.slicer.media/en/api/recipes/folder-to-reels/)
