REST API • Beta

Webhook API
для кросспостинга

Один HTTP-запрос — пост публикуется в Telegram, VK и Max одновременно. Bearer-токен, JSON, Idempotency-Key. Без OAuth, простой формат payload.

Webhook input — Pro+. Sandbox — все тарифы. Webhook output — Standard+.

Один эндпоинт

Вместо трёх API (Telegram Bot, VK, Max) — один webhook URL. Crosslybot конвертирует payload под каждую платформу автоматически.

LLM-friendly

Bearer-токен. ChatGPT, Claude, кастомные AI-агенты подключаются за минуты — стандартный паттерн REST.

Безопасно

Bearer-токен, Idempotency-Key, HMAC-подпись на исходящих, история запросов. Промышленный уровень безопасности.

Минимальный пример за 5 минут

Регистрация → создание webhook → cURL-команда. Полный гайд: /webhook/quickstart.

terminal
curl -X POST 'https://wh.crosslybot.ru/v1/webhooks/8qwv9XeTcR6Nyn2nlIW_wQ' \
  -H 'Authorization: Bearer crossly_live_xxxxxxxxxxxxxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: hello-world-001' \
  -d '{
    "text": "Привет, мир! 👋",
    "media": [
      {"type": "photo", "url": "https://picsum.photos/800"}
    ],
    "buttons": [[
      {"text": "Подробнее", "url": "https://crosslybot.ru"}
    ]]
  }'
Ответ при успехе
200 OK · {"ok": true, "id": "post_..."}
Sandbox без публикации
POST /v1/sandbox/test

Сценарии использования

6 подробных гайдов под основные кейсы. От quick-start cURL до AI-агентов и no-code workflow.

API Reference

Документация API

Полная спецификация: схема payload, заголовки, ответы, повторы, лимиты. Достаточно для production-интеграции.

Форматирование — в формате Telegram Bot API entities

Входящий webhook (IN) принимает форматирование текста в формате Telegram entities: offset, length, type — те же значения и та же семантика. Поддерживаются все 15+ типов: bold, italic, underline, strikethrough, spoiler, code, pre, blockquote, expandable_blockquote, text_link, mention, url, email, phone_number, hashtag, custom_emoji.

Если ваш код уже работает с Telegram Bot API — сериализацию entities переиспользуете как есть. Структура самих сообщений (медиа, альбомы, кнопки) у нас своя, единая для всех платформ — проще чем у TG. Полная таблица отличий ниже ↓

Исходящий webhook (OUT) отдаёт payload в том же формате что принимает IN — text (plain) + entities[] (TG-style массив, offsets/length в UTF-16). Симметрия: один и тот же формат — для отправки и для приёма. Если нужен HTML — конвертируете entities в теги на своей стороне (стандартная функция в TG-SDK).

Базовая информация

  • Base URL: https://wh.crosslybot.ru
  • Метод: POST
  • Path: /v1/webhooks/{slug}
  • Аутентификация: Bearer-токен в заголовке Authorization
  • Content-Type: application/json
  • Кодировка: UTF-8
  • Размер запроса: до 5 МБ

HTTP-заголовки запроса

Заголовок Обяз. Описание
Authorization да Bearer crossly_live_… или crossly_test_… Plaintext-значение показывается один раз при создании — сохраните его сразу.
Content-Type да application/json
Idempotency-Key нет UUID или произвольная уникальная строка. Повторный POST с тем же ключом за 24ч вернёт закэшированный ответ. Длина до 128 символов.
User-Agent нет Желательно указывать имя клиента (например, my-ai-agent/1.0) — отображается в истории запросов

Поля JSON payload

Минимальный валидный пост: должен содержать хотя бы одно из text, media[] или buttons[].

Поле Тип Описание
textstringТекст поста. IN: plain text без HTML-тегов. OUT: plain (HTML-теги вырезаны). Лимит — самый щедрый из платформ (15895 для VK).
entitiesarrayФорматирование в формате Telegram Bot API: bold, italic, underline, strikethrough, spoiler, code, pre, blockquote, text_link, mention. Offsets/length в UTF-16 code units. Идентичный формат в IN (что принимаем) и в OUT (что шлём) — клиент учит формат один раз.
media[]arrayМедиа: до 10 элементов. Каждый — { type: photo|video|audio, url: https://… }. document — отклоняется (400). Альбом = массив media в одном POST'е (в отличие от TG Bot API, где альбом приходит как N updates с одинаковым media_group_id). Никакой буферизации не нужно — публикуется одним media group.
buttons[][]arrayURL-кнопки: ряды × кнопки (до 8×8). Каждая — { text, url }. URL должен начинаться с https:// или tg://. Telegram и Max публикуют как inline-кнопки. VK wall не поддерживает inline-кнопки — для VK включите URL прямо в текст или используйте подпись с ссылкой.
is_advertisementboolМаркер рекламного поста. Не обязателен для применения паузы — используется как метка для статистики
ad_pause_minutesint0–1440. Пауза всего проекта после первой успешной публикации поста (применяется единожды). Уже принятые посты допубликуются, новые в проект не попадают до конца паузы
ad_target_pause_minutesint0–1440. Глобальная пауза каналов опубликованных целей — действует во всех ваших проектах с этими каналами. Применяется в момент успеха публикации. Можно использовать вместе с ad_pause_minutes
external_idstringАльтернатива Idempotency-Key (можно использовать вместе). До 128 символов
trace_idstringПроизвольный ID для end-to-end отладки. Возвращается в response и логах
metadataobjectПроизвольные поля. Сохраняются в Post.extra_data

Лимиты и валидация медиа

  • 📷 photo: до 20 МБ (jpg/png/webp)
  • 🎬 video: до 2 ГБ, до 30 мин (mp4/mov/webm)
  • 🎵 audio: до 500 МБ (mp3/m4a/ogg)
  • 📑 document: отклоняется (400)
  • 📦 Сумма media[]: до 4 ГБ
  • 🔢 Количество: до 10 элементов
  • 🌐 URL: только https://, ≤ 2048 символов, прямая ссылка на файл без редиректов
  • 🛡 Decompression bomb: ≤ 25 МП у фото
Что валидируется: только https://, тип медиа из белого списка (photo/video/audio), размер и реальный mime-type, корректность контейнера. Если URL/файл не проходит валидацию — ответ 400 с деталями в errors[]. Документы и приватные адреса отклоняются.

⚠ Редиректы на файл запрещены — отдавайте прямую ссылку на файл. Если ваш CDN/хостинг возвращает редирект — публикация падает с ошибкой.

HTTP-коды ответов

Код Значение Действие клиента
200Принят, поставлен в очередьГотово
202Уже принят (Idempotency)Тот же результат — без действий
400Невалидный payloadПрочитать errors[] и исправить
401Slug не найден или Bearer невалиден (одинаковый ответ для защиты от энумерации)Проверить URL и токен. Перевыпустить токен в /channels
402Тариф не позволяетУлучшить тариф / купить пакет постов
403Endpoint деактивированАктивировать в UI
422Невалидная JSON-схемаПрочитать errors[] в теле — там детали по каждому полю
429Превышен rate limitУчитывать Retry-After header
503Webhook API временно недоступенRetry с backoff
5xxВнутренняя ошибкаRetry с экспоненциальным backoff

Decisions в «Истории запросов»

В UI карточки endpoint'а есть журнал последних 100 запросов. Каждая запись содержит decision — внутренняя причина решения. Это поможет отладить интеграцию когда клиент видит просто 401 или 429:

Decision HTTP Что случилось
accepted200Принят, пост создан и поставлен в очередь публикации
accepted_no_project200Принят, но webhook не привязан ни к одному проекту — пост не создаётся (silent)
accepted_idempotent_replay200Дубль по Idempotency-Key, отдан кешированный ответ
rejected_auth401Slug не существует, токен неверен, IP вне allowlist, signature не сошлась или timestamp вне окна — клиенту отдаётся одинаковый ответ, точная причина в журнале
rejected_validation400/415/422Невалидный JSON, неподходящий Content-Type, payload >5MB или схема не прошла валидацию (детали в errors[])
rejected_rate_limit429Превышен один из лимитов: min-interval, burst или часовая квота. Используйте Retry-After
rejected_paused503Endpoint выключен (вручную или автопауза после rate-limit нарушений)
rejected_tier503Текущий тариф больше не разрешает webhook IN — endpoint автоматически отключён
rejected_disabled503Webhook API временно недоступен (плановые работы)

Примеры кода

Замените 8qwv9XeTcR6Nyn2nlIW_wQ на свой slug (копируется из карточки в /channels) и crossly_live_... на ваш токен.

cURL
curl -X POST https://wh.crosslybot.ru/v1/webhooks/8qwv9XeTcR6Nyn2nlIW_wQ \
  -H "Authorization: Bearer crossly_live_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "text": "Привет, мир!",
    "entities": [{"type": "bold", "offset": 0, "length": 6}],
    "media": [
      {"type": "photo", "url": "https://cdn.example.com/img.jpg"}
    ],
    "external_id": "post-123",
    "metadata": {"source": "ai-agent"}
  }'
Python (requests + retry)
import os, time, uuid, requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=Retry(
    total=4,
    backoff_factor=2,            # 2с / 4с / 8с / 16с
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods=["POST"],
    respect_retry_after_header=True,
)))

SLUG = "8qwv9XeTcR6Nyn2nlIW_wQ"
TOKEN = os.environ["CROSSLY_TOKEN"]

resp = session.post(
    f"https://wh.crosslybot.ru/v1/webhooks/{SLUG}",
    headers={
        "Authorization": f"Bearer {TOKEN}",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
        "User-Agent": "my-ai-agent/1.0",
    },
    json={
        "text": "Привет, мир!",
        "entities": [{"type": "bold", "offset": 0, "length": 6}],
        "media": [{"type": "photo", "url": "https://cdn.example.com/img.jpg"}],
        "external_id": "post-123",
    },
    timeout=30,
)
resp.raise_for_status()
print(resp.json())
Node.js (fetch + retry)
import { randomUUID } from "crypto";

const SLUG = "8qwv9XeTcR6Nyn2nlIW_wQ";
const TOKEN = process.env.CROSSLY_TOKEN;
const BACKOFFS = [2000, 4000, 8000, 16000]; // 2c / 4с / 8с / 16с

async function publish(payload) {
  for (let attempt = 0; attempt < BACKOFFS.length + 1; attempt++) {
    const resp = await fetch(
      `https://wh.crosslybot.ru/v1/webhooks/${SLUG}`,
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${TOKEN}`,
          "Content-Type": "application/json",
          "Idempotency-Key": randomUUID(),
          "User-Agent": "my-ai-agent/1.0",
        },
        body: JSON.stringify(payload),
      }
    );
    if (resp.ok) return resp.json();
    if (![429, 500, 502, 503, 504].includes(resp.status)) {
      throw new Error(`Crosslybot ${resp.status}: ${await resp.text()}`);
    }
    if (attempt === BACKOFFS.length) throw new Error("Max retries exceeded");
    const ra = Number(resp.headers.get("retry-after")) * 1000;
    await new Promise(r => setTimeout(r, ra || BACKOFFS[attempt]));
  }
}

await publish({
  text: "Привет, мир!",
  media: [{ type: "photo", url: "https://cdn.example.com/img.jpg" }],
  external_id: "post-123",
});

Sandbox: тестирование без публикации

Sandbox — отдельный эндпоинт для проверки формата payload и валидации медиа без записи в БД и без публикации в каналы. Ответ совпадает по структуре с боевым: видно какие ошибки, какие цели получили бы пост, какая обрезка текста применилась бы под каждую платформу.

  • URL: POST /v1/sandbox/test
  • Аутентификация: тот же Bearer токен (live или test)
  • Payload: идентичный боевому
  • Не списывает квоту постов, не отображается в истории публикаций
  • Что проверяется: JSON-схема, entities, лимиты длины, https://, тип и реальный mime-type медиа
  • Что НЕ проверяется: реальная доставка в Telegram/VK/Max API (только локальная валидация)
  • Лимиты Free: 100 запросов/день, Mini: 1 000/день, Standard+: ∞
Sandbox-запрос (cURL)
curl -X POST https://wh.crosslybot.ru/v1/sandbox/test \\
  -H "Authorization: Bearer crossly_test_xxxxxxxxxxxxxxxxxxxx" \\
  -H "Content-Type: application/json" \\
  -d '{
    "text": "Тестовый пост",
    "entities": [{"type": "bold", "offset": 0, "length": 8}],
    "media": [
      {"type": "photo", "url": "https://cdn.example.com/img.jpg"}
    ]
  }'
Пример успешного ответа (200)
{
  "ok": true,
  "would_create_post": true,
  "validation": {
    "schema": "passed",
    "media": [{"url": "https://cdn.example.com/img.jpg", "size_mb": 2.4, "ok": true}],
    "entities": "passed"
  },
  "preview": {
    "telegram": {"text_after_trim": "Тестовый пост", "trimmed_chars": 0},
    "vk":       {"text_after_trim": "Тестовый пост", "trimmed_chars": 0},
    "max":      {"text_after_trim": "Тестовый пост", "trimmed_chars": 0}
  }
}

💡 Используйте sandbox в CI/CD как smoke-тест после каждого деплоя интеграции — это бесплатный способ убедиться что payload-генератор не сломан.

Совместимость с Telegram Bot API

Формат payload максимально близок к Telegram Bot API — если у вас уже есть код для TG, большую часть можно переиспользовать. Но есть отличия в моделях, перечислены ниже.

Что У TG Bot API У нас
Форматирование (entities) type/offset/length/url/language/custom_emoji_id, offset в UTF-16 ✓ Тот же формат
Альбом N отдельных update'ов с общим media_group_id Один POST с массивом media[] (атомарно, без буферизации)
Текст и подпись медиа Раздельно: text для сообщения, caption для медиа Один text — он же caption когда есть media[]
Медиа file_id или различные структуры (photo[], video, document) {type, url} — единая структура для photo/video/audio
Кнопки inline_keyboard с callback, switch_inline и пр. Только URL-кнопки {text, url} (callback не портируется на VK/Max). Публикуются в TG и Max; в VK wall не поддерживается — игнорируется.
Идемпотентность update_id в payload Заголовок Idempotency-Key или поле external_id (cache 24h)
Опросы (poll) Поддерживаются Не поддерживаются (опросы не работают на VK/Max единообразно)
Документы Любые файлы Отклоняются (400) — security и совместимость с другими платформами

💡 Если переносите код с TG Bot API: основная адаптация — собирайте альбом в один POST с media[] вместо последовательных send'ов. Entities и URL-кнопки переиспользуются как есть.

Адресная публикация и отложенные паузы

Три независимых механизма управления публикацией прямо из payload — комбинируйте их свободно.

1. Адресная публикация — targets[]

По умолчанию пост идёт во все активные цели проекта. Чтобы ограничить — передайте targets[] с public_id целей (копируется из карточки цели).

{
  "text": "Анонс — только в основные каналы",
  "targets": [
    { "id": "tgt_aBcDeFgHiJkLmNoPqRsTuV" },
    { "id": "tgt_xYz123456789abcdefGhi" }
  ]
}

⚠️ Если ни один из переданных id не совпал с целями проекта — ответ 422 invalid_targets, пост не создаётся.

2. Автопауза проекта — ad_pause_minutes

После первой успешной публикации поста весь проект ставится на паузу на N минут. Применяется один раз — повторные post-target'ы того же поста (multi-target) паузу не продлевают.

{
  "text": "Рекламная вставка",
  "is_advertisement": true,
  "ad_pause_minutes": 60
}

3. Автопауза канала — ad_target_pause_minutes

Каждый успешно опубликованный канал ставится на глобальную паузу — действует во всех ваших проектах с этим каналом. Удобно когда контракт с площадкой требует «не больше одной рекламы в час»: параллельный обычный кросспостинг в этот канал тоже подождёт.

{
  "text": "Реклама в TG-канал на 24 часа",
  "is_advertisement": true,
  "ad_target_pause_minutes": 1440,
  "targets": [
    { "id": "tgt_aBcDeFgHiJkLmNoPqRsTuV" }
  ]
}
  • • Поля ad_pause_minutes и ad_target_pause_minutes независимы — можно одно, оба или ни одного.
  • • Триггер автопаузы — фактическая успешная публикация. Если все цели упали — пауза не применяется.
  • • Пауза цели — глобальная на канал: действует во всех ваших проектах, где этот канал используется. Параллельный обычный кросспостинг в тот же канал тоже подождёт.
  • • Существующая ручная пауза не сокращается. Авто-пауза только продлевает, если новая позже.
  • is_advertisement — метка для статистики, не обязательна для работы пауз.

Пауза без публикации — temp-pause / temp-resume

Автопаузы выше срабатывают вместе с публикацией поста. Если нужно поставить паузу отдельно (например рекламная площадка хочет «забронировать тишину» наперёд) — есть два endpoint'а, которые ничего не публикуют, только управляют паузой.

POST /v1/webhooks/{slug}/temp-pause

{
  "project_minutes": 60,        // пауза всего проекта (опционально)
  "target_minutes": 120,        // глобальная пауза каналов (опционально)
  "targets": ["tgt_aBc..."]     // какие каналы; пусто = все цели проекта
}
  • • Минимум одно из project_minutes / target_minutes должно быть > 0.
  • target_minutes — глобальная пауза ресурса: действует во всех ваших проектах с этим каналом.
  • • Если targets указан, но ни один id не принадлежит проектам этого webhook'а — 422 invalid_targets.
  • • Идемпотентно: не сокращает уже активную более длинную паузу.

POST /v1/webhooks/{slug}/temp-resume

{
  "scope": "all",               // "project" | "targets" | "all" (default all)
  "targets": ["tgt_aBc..."]     // какие каналы снять (если scope=targets)
}

Накопленные за время паузы посты публикуются с интервалом 1 мин после снятия. Оба endpoint'а используют тот же auth-стек (Bearer + опциональный HMAC + timestamp), что и публикация.

Ответ: {ok, request_id, projects_affected, resources_affected, rescheduled_post_targets}.

Discovery endpoint — для MCP и автоматизаций

Чтобы MCP-серверам (Claude Desktop, Cursor) и скриптам не приходилось вручную копировать tgt_… каждой цели и знание о платформах — есть один запрос, который отдаёт всю структуру:

curl https://wh.crosslybot.ru/v1/webhooks/{slug}/info \
  -H "Authorization: Bearer crossly_live_..."

Ответ (режим по умолчанию — обезличенный):

{
  "endpoint": { "slug": "...", "name": null, "verbose": false },
  "projects": [
    {
      "index": 1,
      "name": null,
      "paused_until": null,
      "targets": [
        { "id": "tgt_aBc...", "platform": "telegram", "name": null, "paused_until": null },
        { "id": "tgt_xYz...", "platform": "max", "name": null, "paused_until": null }
      ]
    }
  ],
  "capabilities": {
    "entity_types": ["bold","italic","spoiler", ...],
    "media_types": ["photo","video","audio"],
    "limits": {
      "max_text_length": 15895, "max_media": 10,
      "max_buttons_rows": 8, "max_buttons_per_row": 8,
      "max_payload_bytes": 5242880, "max_pause_minutes": 1440
    }
  }
}

Verbose режим — включается в карточке webhook IN (раздел «Безопасность» → toggle «Раскрывать имена в discovery (/info)»). Тогда в ответе вместо null появятся реальные названия проекта, целей и самого endpoint'а.

  • • Безопасность идентична POST: те же Bearer, IP allowlist, timestamp, HMAC (для GET подписывается method\npath\ntimestamp вместо body), tariff guard.
  • • Отдельный rate-limit 60 запросов/мин — не съедает publishing budget. Violations пишутся в общий счётчик soft-block, атакующий не может через flood /info обойти автозащиту.
  • paused_until для проекта и цели всегда видим — клиент знает что пост встанет в очередь, не публикуется сразу.
  • capabilities.limits — клиент-валидатор может проверить текст/медиа/паузы до отправки и избежать 422.

⚠️ Не возвращаем точные значения rate-limit (min_interval, hourly_quota) — раскрытие даёт атакующему с украденным токеном protocol cheatsheet.

MCP-сервер — для Claude Desktop, Cursor, Cline

Подключите Crosslybot напрямую к AI-ассистенту — он сможет публиковать посты, искать цели по именам, ставить каналы на паузу через простые команды.

Hosted-вариант (рекомендуется) — никаких локальных установок, только URL в конфиге AI-клиента:

// claude_desktop_config.json
{
  "mcpServers": {
    "crosslybot": {
      "url": "https://mcp.crosslybot.ru/sse/{slug}",
      "headers": {
        "Authorization": "Bearer crossly_live_..."
      }
    }
  }
}

Слаг и токен — из карточки webhook IN в личном кабинете. Включите toggle «Раскрывать имена в discovery (/info)» чтобы AI мог находить цели по именам.

Tools AI получает автоматически:

  • crosslybot_discover — список ваших проектов и целей публикации (Telegram/VK/Max).
  • crosslybot_publish — публикация поста. Принимает tgt_… или имя цели ("маркетинг"). Поддерживает все поля payload включая автопаузу проекта/цели после публикации.

С HMAC-защитой — добавьте ещё один header:

"headers": {
  "Authorization": "Bearer crossly_live_...",
  "X-Crosslybot-Hmac-Secret": "..."
}

⚠️ Если включён IP allowlist, добавьте в whitelist IP нашего MCP-сервера: 89.223.125.61.

Self-hosted доступен через Docker (multi-arch) — см. GitHub или образ ghcr.io/antiblef/crosslybot-mcp. Документация: /mcp.

Идемпотентность

Заголовок Idempotency-Key или поле external_id в payload — стандартный способ безопасных повторов запросов.

  • • Ключи кешируются на 24 часа в рамках endpoint
  • • Повторный POST с тем же ключом возвращает 202 и закэшированный ответ — БЕЗ создания дубликата поста
  • • Если первый запрос упал на 5xx и не был обработан — повтор создаст пост впервые
  • • Длина ключа до 128 символов. Рекомендуется UUIDv4 (универсально) или бизнес-ID (стабильно для логики)

Политика повторов (для клиентов)

Когда повторять запрос, и через какое время.

Ситуация Повторять? Стратегия
200 / 202НетГотово
4xx (кроме 429)НетЭто ошибка вашего клиента — повторы не помогут. Прочитайте errors[]
429ДаУчитывайте Retry-After header. Если его нет — backoff 2с/4с/8с/16с
503ДаBackoff 5с/15с/45с/2 мин/5 мин — не более 5 попыток
5xx (кроме 503)ДаЭкспоненциальный backoff 2с/4с/8с/16с — макс 4 попытки. Всегда тот же Idempotency-Key

⚠ ВАЖНО: при retry используйте тот же Idempotency-Key — иначе создастся дубликат поста.

Rate limit и квоты

Четырёхуровневая защита per-slot: настраиваемый интервал + burst + часовая квота по тарифу + автопауза при систематических нарушениях. Превышение → 429 Too Many Requests с заголовком Retry-After.

Тариф Min-interval Burst (per slot) Часовая квота Webhook IN
Free / Mini / Standardнедоступен
Pro1–60 сек10 req/sec100
Maxi1–60 сек10 req/sec300
Business1–60 сек10 req/sec1 000
  • Per-slot изоляция: лимиты привязаны к URL входящего webhook'а (slug), а не к аккаунту — атака на один webhook не задевает другие.
  • Min-interval: «не чаще одного запроса в N секунд», настраивается в карточке endpoint'а (по умолчанию 10s). Защита от случайного флуда из-за бага в bot'е/скрипте отправителя.
  • Burst: sliding window 1 секунда. Защита от мгновенного флуда.
  • Часовая квота: сбрасывается на границе UTC-часа.
  • Автопауза: после 5 нарушений лимита за час (любого уровня — min-interval, burst или часовая квота) endpoint автоматически выключается (is_active=false). Владельцу уведомление в Telegram-бот. Включается обратно вручную через UI после устранения причины — авто-восстановления нет, чтобы цикл «пауза→повторный абьюз→пауза» не повторялся.

Лимит постов (как у обычных целей): каждая успешная публикация webhook-поста = 1 пост в месячной квоте тарифа. Не путайте с rate limit (защита от абьюза) и квотой постов (биллинг).

Дополнительные защиты webhook IN

Помимо HTTPS + Bearer-токена, в карточке endpoint'а доступны опциональные защиты для повышенной безопасности.

  • HMAC-подпись от отправителя. Defense-in-depth: отправитель подписывает body своим секретом, мы проверяем. Даже если Bearer-токен утечёт — без секрета подделать запрос не получится. Включается toggle в карточке endpoint'а. Проверка идентична нашему OUT-варианту (см. ниже про Webhook OUT) — алгоритм один, клиенту достаточно одной функции для приёма и отправки. Примеры кода — ниже.
  • Replay protection через timestamp. При require_timestamp=on требуем заголовок X-Crosslybot-Timestamp (Unix seconds) в окне ±5 мин. Защита от replay-атак если body+token утекли в логи или были перехвачены: запрос валиден только в 5-минутном окне, потом — 401.
  • IP allowlist (CIDR). Список IP/CIDR из которых принимаем запросы. Запросы с других адресов получают идентичный 401 (timing-safe, не раскрываем что allowlist настроен). Имеет смысл если у отправителя статичный IP — для облачных SaaS (n8n cloud, Zapier, Make, AWS Lambda) IP-пулы плавающие, тогда лучше не использовать.
  • Ротация публичного URL. Если адрес webhook'а попал в открытые источники — нажмите «Rotate URL» в карточке. Сгенерируется новый /v1/webhooks/<new-slug>, старый мгновенно перестаёт работать. Cooldown 24 ч от случайных кликов. Endpoint, токен и все привязки в проектах при ротации сохраняются — после ротации просто обновите URL у всех отправителей.
  • История запросов в UI. На странице endpoint'а — последние 100 запросов с фильтрами по статусу. Виден IP, decision, причина, latency, размер payload. Удобно для отладки интеграции и расследования инцидентов.

Подпись запроса от отправителя (если включён require_signature)

Алгоритм: HMAC-SHA256(secret, raw_body) → hex → заголовок X-Crosslybot-Client-Signature: sha256=<hex>. Принимается также формат без префикса sha256= и регистр hex (UPPERCASE/lowercase). Подпись валидируется до основной обработки запроса; при несовпадении — идентичный 401 (как для невалидного токена, timing-safe).

bash + openssl
SECRET="ваш-сгенерированный-secret"
BODY='{"text":"привет"}'
SIG=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')

curl -X POST https://wh.crosslybot.ru/v1/webhooks/<slug> \
  -H "Authorization: Bearer crossly_live_..." \
  -H "Content-Type: application/json" \
  -H "X-Crosslybot-Client-Signature: sha256=$SIG" \
  --data-binary "$BODY"
Python
import hmac, hashlib, json, requests

SECRET = "ваш-secret"
body_bytes = json.dumps({"text": "привет"}).encode("utf-8")
signature = hmac.new(SECRET.encode(), body_bytes, hashlib.sha256).hexdigest()

resp = requests.post(
    "https://wh.crosslybot.ru/v1/webhooks/<slug>",
    headers={
        "Authorization": "Bearer crossly_live_...",
        "Content-Type": "application/json",
        "X-Crosslybot-Client-Signature": f"sha256={signature}",
    },
    data=body_bytes,  # ВАЖНО: data=, не json= — нужны точные байты
)
Node.js
import { createHmac } from "crypto";

const SECRET = "ваш-secret";
const body = JSON.stringify({ text: "привет" });
const signature = createHmac("sha256", SECRET).update(body).digest("hex");

await fetch("https://wh.crosslybot.ru/v1/webhooks/<slug>", {
  method: "POST",
  headers: {
    "Authorization": "Bearer crossly_live_...",
    "Content-Type": "application/json",
    "X-Crosslybot-Client-Signature": `sha256=${signature}`,
  },
  body, // ВАЖНО: точные байты, не пересериализовывать
});
Go
import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "net/http"
)

secret := []byte("ваш-secret")
body := []byte(`{"text":"привет"}`)

mac := hmac.New(sha256.New, secret)
mac.Write(body)
sig := hex.EncodeToString(mac.Sum(nil))

req, _ := http.NewRequest("POST", "https://wh.crosslybot.ru/v1/webhooks/<slug>", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer crossly_live_...")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Crosslybot-Client-Signature", "sha256="+sig)
http.DefaultClient.Do(req)

⚠ Подпись считается от точных байтов body. Если ваша HTTP-библиотека пересериализует JSON между подписью и отправкой — подпись не сойдётся, получите 401. Используйте data=bytes / --data-binary / эквиваленты.

Webhook OUT: проверка HMAC-подписи

Когда Crosslybot отправляет пост на ваш URL (исходящий webhook), мы подписываем тело запроса HMAC-SHA256 и кладём подпись в заголовок X-Crosslybot-Signature. Это позволяет приёмнику убедиться что запрос действительно от Crosslybot.

Заголовки исходящего webhook
X-Crosslybot-Signature: sha256=<hex>
X-Crosslybot-Delivery-Id: <uuid>
X-Crosslybot-Timestamp: <unix-seconds>
X-Crosslybot-Event: post.published
Idempotency-Key: <uuid>
Content-Type: application/json
Проверка на стороне приёмника (Python)
import hmac, hashlib

def verify(secret: str, raw_body: bytes, header_signature: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"), raw_body, hashlib.sha256
    ).hexdigest()
    # constant-time сравнение чтобы избежать timing-атак
    return hmac.compare_digest(expected, header_signature)

# Пример (Flask):
# raw = request.get_data()
# sig = request.headers["X-Crosslybot-Signature"]
# if not verify(SECRET, raw, sig): abort(401)

Управление токенами

  • Plaintext показывается ОДИН раз — при создании или rotate. Сохраните сразу в свой secret-менеджер
  • Префиксы: crossly_live_ для прода, crossly_test_ для тестирования (легко искать в логах)
  • Rotate: мгновенная замена. Старый токен немедленно перестаёт работать
  • Revoke: в UI можно отозвать токен или временно деактивировать endpoint
  • Несколько токенов: в MVP — один активный токен на endpoint. Если нужно дать токен второй интеграции — создайте отдельный webhook

Best practices

  • Хранение токена: только в env-переменных или secret-менеджерах. Не в коде, не в Git
  • Idempotency-Key всегда: даже если сейчас не делаете retry. Защитит от случайных дубликатов при сбоях
  • User-Agent: укажите имя клиента — упростит отладку через историю запросов
  • trace_id: сквозной идентификатор от вашего сервиса до публикации — полезно для распределённого трейсинга
  • Sandbox перед прод: POST /v1/sandbox/test для CI/CD smoke-тестов на каждом деплое
  • Лимит размера media: сжимайте до отправки если знаете что цели имеют меньшие лимиты, чтобы избежать дополнительной AI-обработки
  • Мониторинг ответов: логируйте status_code и trace_id из 200/202 ответов
  • Не блокируйте свой код: отправка webhook должна быть фоновой задачей с retry, а не в hot-path обработки запроса пользователя

Тарифы и лимиты

Sandbox-тестер доступен на любом тарифе. Реальный приём webhook'ов и доставка наружу — на платных.

Возможность Free Mini Standard Pro Maxi Business
Sandbox /v1/sandbox/test 100/день 1k/день
Webhook output
Webhook input
Burst rate (input) 1/5с 1/2с 1/1с
Quota / hour (input) 300 1 000 5 000

Готов попробовать?

Создайте webhook за минуту, протестируйте формат через sandbox, подключите AI или свой бэкенд.