# Вебхуки

> Вебхуки Slicer API — события о загрузках, монтаже, экспорте и публикациях, подпись HMAC-SHA256 и её проверка на Node.js и Python, повторы доставки.

Источник: https://docs.slicer.media/api/webhooks/

Вебхук — адрес вашего сервера, на который Slicer сам отправляет событие, когда что-то произошло: загрузка готова, монтаж закончился, ролик опубликован. Это быстрее и экономнее опроса: не нужно спрашивать статус каждые 5 секунд, и запросы не расходуют [лимит](https://docs.slicer.media/api/rate-limits/).

## Добавить вебхук

Нужна область `webhooks`. Адрес — только публичный `https`; до 5 вебхуков на аккаунт.

Терминал:

```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":"Уведомления в рабочий чат"}'
```

Ответ:

```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": "Уведомления в рабочий чат",
      "enabled": true,
      "disabledReason": null,
      "failures": 0,
      "lastDeliveryAt": null,
      "createdAt": "2026-10-16T09:30:00.000Z"
    },
    "secret": "whsec_…"
  }
}
```

`secret` — ключ подписи. Он показывается **один раз**: сохраните его в переменной окружения вашего сервера, например `SLICER_WEBHOOK_SECRET`. Вебхуки можно добавлять и в приложении: **Профиль** → «Для разработчиков» → «Вебхуки».

## События

| Событие | Когда приходит | `data` |
| --- | --- | --- |
| `upload.ready` | загрузка проверена и готова к монтажу (в том числе импорт по адресу) | загрузка |
| `upload.failed` | файл не принят | загрузка с причиной в `error` |
| `project.succeeded` | монтаж, пересборка или повтор завершились | проект |
| `project.failed` | запуск не получился, резерв токенов вернулся | проект с `error` |
| `project.cancelled` | запуск остановлен | проект |
| `project.version_ready` | версии ролика ждут выбора — приходит после `project.succeeded`, только если монтаж отдал несколько версий. Сейчас монтаж отдаёт одну, и этого события нет: ждите `project.succeeded` | проект с `versionChoice` |
| `export.succeeded` | экспорт готов, файл обновлён | проект |
| `export.failed` | экспорт не получился, прежний ролик сохранён | проект с `error` |
| `publication.published` | ролик опубликован в соцсети | публикация с `url` и `projectId` |
| `publication.failed` | публикация не удалась | публикация с `errorCode` и `projectId` |
| `ping` | проверка вебхука (`POST /v1/webhooks/{id}/test`) — отправляется сразу, без повторов | `webhookId` и `message` |

Список событий может пополняться ([Версии и совместимость](https://docs.slicer.media/api/versioning/)): пропускайте незнакомые типы, отвечая `2xx`.

## Формат запроса

Slicer отправляет `POST` с JSON-телом и служебными заголовками:

Запрос к вашему серверу:

```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": "Монтаж за 3 минуты", "status": "succeeded", "appUrl": "https://app.slicer.media/projects/318" }
}
```

-   `id` — уникальный id события (`evt_…`); по нему отбрасывайте повторы.
-   `type` — тип события, он же — в заголовке `Slicer-Event`.
-   `data` — объект в том же виде, что отдаёт API (`GET /v1/projects/{id}`, `GET /v1/uploads/{id}` или публикация).
-   `Slicer-Delivery` — номер доставки: по нему доставку легко найти в журнале `GET /v1/webhooks/{id}/deliveries`.

## Проверка подписи

Подпись доказывает, что запрос пришёл от Slicer и не изменён по дороге. Заголовок `Slicer-Signature` содержит время отправки `t` (Unix-секунды) и подпись `v1` — HMAC-SHA256 в hex от строки `<t>.<тело запроса>` на ключе `secret`.

Как проверять:

1.  Возьмите **сырое тело** запроса — байты как пришли, до разбора JSON. После разбора и повторной сериализации подпись не сойдётся.
2.  Посчитайте HMAC-SHA256 от `t`, точки и тела на своём секрете.
3.  Сравните с `v1` **за постоянное время** (`timingSafeEqual`, `hmac.compare_digest`), а не обычным `==`.
4.  Отклоните запрос, если `t` отличается от текущего времени больше чем на 5 минут, — это защищает от повторной отправки перехваченного запроса.

**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, если подпись верна и запрос не старше 5 минут. */
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(); // в продакшене — база или Redis

// express.raw: тело приходит Buffer-ом, ровно как его подписал Slicer
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); // ответить быстро, обработать после

  if (seen.has(event.id)) return;
  seen.add(event.id);
  setImmediate(() => {
    if (event.type === 'project.succeeded') console.log(`Ролик готов: ${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, если подпись верна и запрос не старше 5 минут."""
    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()  # в продакшене — база или Redis

@app.post("/slicer/webhook")
def slicer_webhook():
    raw = request.get_data()  # сырое тело, до разбора JSON
    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"Ролик готов: {event['data']['title']} — {event['data']['appUrl']}")
    return "", 204
```

## Доставка и повторы

-   **Успех** — ответ `2xx` не дольше чем за 10 секунд. Отвечайте сразу, а долгую работу делайте после ответа или в очереди.
-   **Повторы** — при другом ответе, таймауте или ошибке соединения: через 1 минуту, 5 минут, 30 минут, 2 часа, 6 часов и 12 часов — всего до 7 попыток.
-   **Перенаправления не выполняются.** Ответ `3xx` считается неудачной доставкой — указывайте окончательный адрес.
-   **Порядок не гарантирован**, а одно событие может прийти дважды (например, если ваш ответ потерялся). Отбрасывайте повторы по `id`, а актуальное состояние при сомнениях берите из API.
-   **Отключение** — после 50 неудачных попыток доставки подряд вебхук выключается (`enabled: false`, причина в `disabledReason`), на почту аккаунта приходит письмо. Исправьте сервер и включите вебхук: `PATCH /v1/webhooks/{id}` с `{"enabled": true}`.
-   Адрес проверяется при создании и перед каждой отправкой: если он перестал указывать на публичный адрес, доставки не будет.

## Проверка и журнал

Терминал:

```bash
# отправить тестовое событие ping
curl -X POST https://api.slicer.media/v1/webhooks/$WEBHOOK_ID/test -H "Authorization: Bearer $SLICER_TOKEN"

# последние 100 доставок: статус, код ответа, попытки
curl https://api.slicer.media/v1/webhooks/$WEBHOOK_ID/deliveries -H "Authorization: Bearer $SLICER_TOKEN"
```

Изменить адрес, события или описание — `PATCH /v1/webhooks/{id}`, удалить — `DELETE`. Все эндпоинты — в [справочнике](https://docs.slicer.media/api/reference/webhooks/).

> **Совет.** Для проверки на своём компьютере нужен публичный `https`\-адрес: адреса вида `localhost` и внутренние сети Slicer не принимает. Подойдёт туннель (например, `cloudflared` или `ngrok`) к локальному серверу.

## Что дальше

[Уведомления через вебхукРецепт: сообщение в чат, когда ролик готов.](https://docs.slicer.media/api/recipes/webhook-notify/)[Справочник: вебхукиСоздание, изменение, проверка и журнал доставок.](https://docs.slicer.media/api/reference/webhooks/)[БезопасностьКак Slicer защищает доставку событий.](https://docs.slicer.media/security/)
