Zapnoty — Webhooks — входящие события

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

REST API для уведомлений через Telegram и Max. Подписчики, OTP, рассылки, формы, helpdesk.

Вебхуки

Zapnoty отправляет HTTP POST на ваш URL при наступлении событий. Один вебхук на проект. Настраивается в дашборде или через API.

Как настроить вебхук

Вебхуки настраиваются как независимые эндпоинты — до 5 на проект. У каждого свой URL, свой набор событий и свой секрет подписи. Создать эндпоинт можно через API или во вкладке «Вебхук» в кабинете проекта — оба способа работают с одними и теми же эндпоинтами.

Создание эндпоинта — POST /v1/webhooks. Поля: url (обязательно), events (список событий; пустой массив — подписка на все события), description (опциональная заметка).

# Создать webhook-эндпоинт (до 5 на проект)
POST https://api.zapnoty.com/v1/webhooks
Authorization: Bearer zn_live_...
Content-Type: application/json
{
"url": "https://example.com/zapnoty/hook",
"events": ["delivery.failed", "subscription.created"],
"description": "Прод-обработчик"
}
# Ответ — поле secret показывается ОДИН раз:
{
"id": "6f1c2e8a-...",
"url": "https://example.com/zapnoty/hook",
"events": ["delivery.failed", "subscription.created"],
"description": "Прод-обработчик",
"status": "active",
"secret": "whsec_..."
}

Управление существующими эндпоинтами — GET для списка, PUT для изменения (url, events, description, status active/disabled), DELETE для удаления. Секрет можно ротировать без простоя: POST /v1/webhooks/{id}/rotate-secret выдаёт новый секрет, старый остаётся валидным ещё 24 часа.

# Список эндпоинтов проекта
GET https://api.zapnoty.com/v1/webhooks
# Обновить эндпоинт (любое поле опционально)
PUT https://api.zapnoty.com/v1/webhooks/{id}
{ "url": "...", "events": [...], "description": "...", "status": "disabled" }
# Удалить эндпоинт
DELETE https://api.zapnoty.com/v1/webhooks/{id}
# Ротация секрета — старый валиден ещё 24 ч (grace-период)
POST https://api.zapnoty.com/v1/webhooks/{id}/rotate-secret

Поле secret возвращается только один раз — при создании эндпоинта и при ротации. Сохраните его сразу: повторно получить секрет нельзя, только ротировать.

Формат доставки

Webhook приходит POST-запросом. Тело всегда — объект { event, data }: event — имя события, data — полезная нагрузка события. Метаданные (timestamp, delivery_id, имя события, подпись) передаются HTTP-заголовками, не в теле.

POST {ваш webhook URL}
Content-Type: application/json
# HTTP-заголовки запроса:
X-Zapnoty-Event: subscription.created
X-Zapnoty-Timestamp: 1747676400
X-Zapnoty-Delivery-Id: 6f1c2e8a-... (UUID — для дедупликации)
X-Zapnoty-Signature: t=1747676400,v1=9a3f...
# Тело — всегда { event, data }:
{
"event": "subscription.created",
"data": { ... }
}

X-Zapnoty-Delivery-Id одинаков для всех повторных попыток одной доставки — используйте его для дедупликации.

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

Заголовок X-Zapnoty-Signature имеет формат t=<unix_timestamp>,v1=<hex>. Подписывается строка «timestamp + точка + сырое тело запроса»:

# Псевдокод проверки подписи (любой язык)
ts, v1_list = parse(header "X-Zapnoty-Signature") # t=<ts>, один+ v1=<hex>
signed_payload = ts + "." + raw_request_body # raw_body — байты как пришли
expected = hex( HMAC_SHA256(webhook_secret, signed_payload) )
valid = any( constant_time_eq(v1, expected) for v1 in v1_list )
  • signed_payload = "<timestamp>" + "." + raw_body — hex-подпись в нижнем регистре
  • Подписывайте СЫРОЕ тело запроса, а не результат JSON.parse → JSON.stringify (порядок ключей/пробелы изменятся).
  • timestamp для проверки берите из t= ВНУТРИ X-Zapnoty-Signature — именно с этим значением считалась подпись. Отдельный заголовок X-Zapnoty-Timestamp дублирует его лишь для удобства, для верификации используйте t=.
  • При ротации секрета (grace-период) приходит несколько v1= — подходит совпадение с любой.

⚠️ timestamp в подписи — только для целостности, НЕ replay-окно. Webhook может прийти повторной попыткой спустя часы (ретраи — до 24 ч), поэтому НЕ отклоняйте «устаревшие» webhook по timestamp — иначе потеряете доставки. Для защиты от повторной обработки используйте дедупликацию по X-Zapnoty-Delivery-Id (он одинаков для всех попыток одной доставки).

// Node.js. ВАЖНО: подписывается timestamp + "." + СЫРОЕ тело,
// а не голое тело и не JSON.stringify(распарсенного).
const crypto = require('crypto');
function verifyZapnoty(rawBody, sigHeader, secret) {
// sigHeader: "t=1747676400,v1=<hex>[,v1=<hex>]"
const parts = sigHeader.split(',');
const ts = parts.find(p => p.startsWith('t='))?.slice(2);
const sigs = parts.filter(p => p.startsWith('v1=')).map(p => p.slice(3));
if (!ts || sigs.length === 0) return false;
const expected = crypto.createHmac('sha256', secret)
.update(ts + '.' + rawBody)
.digest('hex');
// При ротации секрета приходит несколько v1= — подходит любое совпадение.
return sigs.some(s => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

События

При настройке webhook-эндпоинта можно подписаться на конкретные события или на все сразу (пустой список). Полный набор:

  • subscription.created / .deleted / .permission_changed
  • delivery.success / delivery.failed
  • broadcast.completed / .cancelled · batch.completed
  • otp.sent / .verified / .failed_attempt / .max_attempts_reached
  • auth.completed · button.clicked
  • ticket.created / .replied / .status_changed / .assigned / .closed / .csat_received / .unanswered_24h
  • scheduled.sent / .failed / .skipped · drip.step_sent / .completed · recurring.sent
  • event.tracked · form.submitted

Подписки

subscription.created, subscription.deleted и auth.completed несут одинаковый набор полей подписчика. subscriber_id — стабильный UUID (без префикса), единственный канонический идентификатор.

  • subscription.created — пользователь подписался любым путём (deep-link, Mini App, авторизация и т.п.).
  • auth.completed — завершилась именно auth-сессия (/v1/auth/session); только это событие содержит ваш state и auth_time. Для привязки subscriber_id к своему user_id слушайте auth.completed.
{
"event": "subscription.created",
"data": {
"subscriber_id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "telegram",
"external_id": "user-42",
"first_name": "Иван",
"username": "ivan",
"lang": "ru",
"permissions": ["news"],
"tags": [],
"active": true,
"blocked": false,
"created_at": "2026-05-21T12:00:00+00:00"
}
}

Недоставка — delivery.failed

Поле reason — машиночитаемая причина. retryable — стоит ли повторять отправку. should_unsubscribe — нужно ли убрать получателя из аудитории (используйте этот флаг вместо разбора текста error).

{
"event": "delivery.failed",
"data": {
"subscriber_id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "telegram",
"external_id": "user-42",
"reason": "user_blocked_bot",
"retryable": false,
"should_unsubscribe": true,
"error": "Forbidden: bot was blocked by the user",
"attempted_at": "2026-05-21T12:00:00+00:00"
}
}

user_blocked_bot — Пользователь заблокировал бота. should_unsubscribe: true

chat_not_found — Чат не найден / аккаунт удалён. should_unsubscribe: true

bot_banned — Бот забанен платформой. retryable: false

invalid_message — Невалидный payload сообщения (медиа, разметка). retryable: false

rate_limited — Лимит платформы (429). retryable: true

platform_unavailable — 5xx / сетевая ошибка платформы. retryable: true

unknown — Неклассифицированная ошибка — нужен ручной разбор.

Завершение рассылки — broadcast.completed

Приходит, когда массовая рассылка (/v1/broadcast) полностью обработана. job_id совпадает с тем, что вернул POST /v1/broadcast. sent — успешно доставлено, failed — ошибки доставки, skipped — пропущено (неактивные/отписавшиеся), total — сумма всех трёх. Событие broadcast.cancelled несёт те же поля для отменённой рассылки.

{
"event": "broadcast.completed",
"data": {
"job_id": "a1b2c3d4-5e6f-7890-abcd-ef1234567890",
"sent": 980,
"failed": 12,
"skipped": 8,
"total": 1000
}
}

Политика ретраев

При ошибке доставки webhook автоматически повторяется до 3 раз с exponential backoff (1с → 2с → 4с). Таймаут каждой попытки — 10 секунд. При 4xx от вашего сервера повторы не выполняются. Рекомендуем реализовать идемпотентную обработку на своей стороне.

Связанные разделы