Skip to content

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.

On this page

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 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 (opens in a new tab)) and the id of the chat to write to.

The server

notify.mjs
// 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
    node notify.mjs &
    cloudflared tunnel --url http://localhost:3000

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

  2. Register the webhook

    Terminal
    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
    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