Перейти к основному содержимому

📤 Исходящие вебхуки

Что это. Fixorix сам отправляет POST-запрос на ваш HTTPS-адрес, как только появляется событие подходящего типа. Запрос подписан, неудачные доставки повторяются, каждая попытка попадает в журнал.

Когда нужно. Когда ваша система должна реагировать на события платформы сразу, а не выяснять изменения регулярными запросами к REST API.

Каталога типов событий пока нет

Подписаться можно на любой тип события, но списка типов, которые публикует сам продукт, не существует: тип события — произвольная строка, её задаёт та система, которая пишет событие. Ни один сервис Fixorix пока в этот контур события не публикует, поэтому реальных доставок по подписке сейчас не будет — кроме тестовой отправки.

Где находится

Раздела вебхуков в интерфейсе нет. Подписки заводятся через управляющее API сервиса вебхуков — базовый путь /webhook-service/api/v1/.

Управляющее API закрыто служебным токеном

Все методы управления требуют заголовок Authorization: Bearer <служебный токен>. Это не API-ключ проекта: ключом из раздела Настройки → вкладка «API» управлять вебхуками нельзя. Наружу управляющее API на тестовом стенде не выставлено — снаружи доступна только точка приёма входящих вебхуков.

Как раздел будет выглядеть, когда появится в дашборде, описано на странице Вебхуки на дашборде.

Подписка на события

Подписка — это связка «проект + адрес получателя + источник событий + список типов».

ПолеОбязательноОписание
ПроектдаПроект, которому принадлежит подписка. Событие доставляется только подпискам того же проекта
Адрес получателядаHTTPS-адрес, на который уходит POST. Максимум 2048 символов. Схема http отклоняется
Источник событийдаИмя потока, из которого сервис читает события. Формат — [A-Za-z0-9][A-Za-z0-9._-]{0,248}
Типы событийдаСписок типов, на которые подписан вебхук. Без хотя бы одного типа подписка не получит ни одной доставки. Формат каждого типа — [A-Za-z0-9][A-Za-z0-9._:@-]{0,127}

Метод регистрации — POST /webhook-service/api/v1/outbound-webhooks. В ответ приходит идентификатор подписки и секрет подписи.

Правила регистрации

  • Повторная регистрация того же адреса ничего не ломает. Пара «проект + адрес» — ключ идемпотентности: повторный вызов вернёт уже существующую подписку и не создаст дубль. Секрет при этом не выдаётся повторно.
  • Лимит — 10 подписок на проект. При превышении возвращается 409 с кодом OUTBOUND_WEBHOOK_LIMIT. Идемпотентный повтор уже зарегистрированного адреса под лимит не попадает.
  • Адрес и источник событий после создания не меняются. Менять можно только набор типов — методом PATCH /webhook-service/api/v1/outbound-webhooks/{id}/types с полями add и remove. Чтобы сменить адрес, подписку удаляют и заводят заново.
  • У каждой подписки свой набор типов. Одно событие может породить несколько доставок — по одной на каждую подписку того же проекта и того же источника, подписанную на этот тип.

Ошибки регистрации:

КодЧто означает
400 not_httpsАдрес не начинается с https://
400 invalid_urlАдрес не разбирается или в нём нет хоста
400 unresolvable_hostИмя хоста не резолвится
400 blocked_targetХотя бы один IP-адрес хоста попадает в запрещённые диапазоны
400 invalid_topicИмя источника событий не подходит по формату
400 invalid_typeОдин из типов событий не подходит по формату
409 OUTBOUND_WEBHOOK_LIMITВ проекте уже 10 подписок
Секрет показывается один раз

Секрет подписи выдаётся ровно один раз — в ответе на регистрацию. Прочитать его позже нельзя ни одним методом: при повторной регистрации того же адреса в ответе будет пустое значение. Потерянный секрет восстанавливается только ротацией — она выдаёт новый.

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

ПараметрЗначениеЧто делает
МетодPOSTДругой метод не используется
Схематолько httpsДоставка по http невозможна
Content-Typeapplication/jsonТело всегда JSON
Теловесь конверт события, байт в байтОтправляется целиком, а не только полезная нагрузка
Таймаут ответа3 секундыНе успели ответить — попытка считается неудачной
Таймаут соединения1 секундаСтолько же отводится на получение соединения из пула
РедиректыотключеныОтвет 3xx — терминальный отказ, переход по Location не выполняется
Cookieне хранятсяСессию на стороне получателя держать не получится
Максимальный размер262 144 байта (256 КиБ)Событие крупнее не отправляется вовсе
User-AgentFixorix-Webhook/1.0По нему доставку удобно отличать в ваших логах
Успехлюбой ответ 2xxВсё остальное — неудача

Конверт события

В теле приходит конверт целиком:

{
"id": "evt-20260824-000123",
"projectId": "1f2e3d4c-5b6a-7890-abcd-ef1234567890",
"type": "ticket.created",
"createdAt": "2026-08-24T10:15:30Z"
}

Поля id, projectId и type в конверте обязательны, createdAt — нет. Подробнее — на странице Типы событий.

Заголовки доставки

ЗаголовокЧто содержит
X-Webhook-IdИдентификатор события. По нему получатель дедуплицирует повторные доставки
X-Webhook-TypeТип события
X-Webhook-Project-IdUUID проекта
X-Webhook-TimestampМетка времени в секундах epoch. Именно она вошла в подпись
X-SignatureHMAC-SHA256 текущим действующим секретом, hex в нижнем регистре
X-Signature-SecondaryВторая подпись прежним секретом. Приходит только в окне ротации
User-AgentFixorix-Webhook/1.0

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

Подписывается строка из метки времени, точки и сырого тела запроса:

подпись = HMAC_SHA256(секрет, X-Webhook-Timestamp + "." + сырое тело)

Правила:

  • алгоритм — HMAC-SHA256, ключ — секрет подписи в UTF-8;
  • результат — hex в нижнем регистре, без префикса алгоритма;
  • тело берётся сырым, до разбора JSON: любое переформатирование ломает подпись;
  • заголовки в подпись не входят — ни идентификатор события, ни тип, ни идентификатор проекта. Подписываются только метка времени и тело.

Пример на Node.js

const crypto = require('node:crypto');

function safeEqual(a, b) {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}

// rawBody — тело запроса строкой, ДО разбора JSON
function verify(rawBody, headers, secret) {
const timestamp = headers['x-webhook-timestamp'];
if (!timestamp) return false;

// допуск по времени выбираете вы сами, здесь — 5 минут
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;

const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + rawBody, 'utf8')
.digest('hex');

// в окне ротации подходящей может оказаться любая из двух подписей
return safeEqual(expected, headers['x-signature'] || '')
|| safeEqual(expected, headers['x-signature-secondary'] || '');
}

Пример на Python

import hashlib
import hmac
import time


def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
timestamp = headers.get("X-Webhook-Timestamp")
if not timestamp:
return False

# допуск по времени выбираете вы сами, здесь — 5 минут
if abs(int(time.time()) - int(timestamp)) > 300:
return False

signed = timestamp.encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

for header in ("X-Signature", "X-Signature-Secondary"):
received = headers.get(header)
if received and hmac.compare_digest(expected, received):
return True
return False

Что должен делать получатель

ПравилоЗачем
Сравнивать подписи константным временемОбычное сравнение строк выдаёт длину совпадающего префикса
Отклонять слишком старую X-Webhook-TimestampЗащита от повторной отправки перехваченного запроса
Дедуплицировать по X-Webhook-IdДоставка гарантируется «хотя бы один раз»: один и тот же идентификатор может прийти дважды
В окне ротации принимать любую из двух подписейПозволяет обновить секрет без потери валидных доставок
Отвечать быстро, работу делать асинхронноНа ответ отводится 3 секунды
Коротко

Возьмите сырое тело, приклейте к нему спереди метку времени и точку, посчитайте HMAC-SHA256 своим секретом и сравните с X-Signature — или с X-Signature-Secondary, если она пришла.

Ротация секрета

Метод POST /webhook-service/api/v1/outbound-webhooks/{id}/secret/rotate возвращает новый секрет. Порядок такой:

  1. Прежний запасной секрет, если он был, удаляется.
  2. Действующий секрет становится запасным и получает срок жизни — 86 400 секунд, то есть сутки.
  3. Создаётся новый действующий секрет и возвращается в ответе.

Сутки после ротации каждая доставка подписывается дважды: X-Signature — новым секретом, X-Signature-Secondary — прежним. За это окно получателю нужно подставить новый секрет у себя. По истечении окна прежний секрет перестаёт использоваться, и приходит только одна подпись.

Одновременно действующих секретов не бывает больше двух.

Статусы доставки

СтатусЧто означает
PENDING — в очередиЖдёт отправки или следующей попытки. В этом же статусе копятся доставки, пока подписка на паузе
IN_PROGRESS — отправляетсяПопытка выполняется прямо сейчас
DELIVERED — доставленоПолучен ответ 2xx
FAILED — отказТерминальный отказ, повторов не будет
EXHAUSTED — исчерпанаЗакончились все попытки

Доставка, зависшая в статусе IN_PROGRESS дольше минуты, автоматически возвращается в очередь.

Повторные попытки

Максимум — 10 попыток на одну доставку.

ПопыткаЧерез сколько после предыдущей
1сразу
210 секунд
330 секунд
41 минута
52 минуты
65 минут
710 минут
830 минут
91 час
102 часа

Если получатель недоступен всё это время, последняя попытка приходится примерно на 3 часа 49 минут после первой.

Когда повтор будет, а когда нет

Ответ получателяЧто происходит
Любой 2xxДоставлено, повторов нет
408, 409, 425, 429, 500, 502, 503, 504Повтор по расписанию
Сетевой сбой: ответа нет вовсеПовтор по расписанию
400, 401, 403, 404, 422Терминальный отказ сразу
Любой другой код, которого нет в списке повторяемых, — включая 3xxТерминальный отказ сразу
Автоматического отключения подписки нет

Серия провалов подписку не выключает. После 10 неудачных попыток конкретная доставка получает статус EXHAUSTED («Исчерпана»), а сама подписка остаётся активной и продолжает получать новые доставки. Ни счётчика подряд идущих ошибок, ни порога, ни автопаузы в сервисе нет — приостановить подписку можно только вручную.

Три случая дают терминальный отказ немедленно, без единого повтора: адрес получателя не HTTPS, событие крупнее 256 КиБ, у подписки нет действующего секрета.

Причины сбоя

Причина записывается в журнал попыток:

ПричинаЧто означает
okУспешный ответ
4xxОтвет из диапазона 4xx; сюда же попадает превышение размера события
5xxОтвет из диапазона 5xx
3xxРедирект — не выполняется, считается отказом
timeoutПолучатель не ответил за отведённое время
dnsИмя хоста не разрешилось
blockedВсе адреса хоста оказались в запрещённых диапазонах или схема не HTTPS
connectНе удалось установить соединение
tlsОшибка TLS-рукопожатия или сертификата
ioОбрыв или ошибка чтения-записи

Пауза и возобновление

Методы POST /webhook-service/api/v1/outbound-webhooks/{id}/pause и POST /webhook-service/api/v1/outbound-webhooks/{id}/resume.

На паузе доставки копятся, а не пропадают

Приостановленная подписка продолжает набирать доставки: события по-прежнему становятся строками в очереди со статусом PENDING, просто отправка не выполняется. После возобновления накопленная очередь начнёт доставляться. Если получатель лежит долго, к моменту возобновления очередь может оказаться большой.

Тестовая отправка

Метод POST /webhook-service/api/v1/outbound-webhooks/{id}/test. Тело необязательно, в нём можно задать тип события и полезную нагрузку.

  • Если тип не задан — используется webhook.test. Если задан, он проверяется по общему формату типа события, иначе 400 invalid_type.
  • Если нагрузка не задана — подставляется {"test": true}.
  • Идентификатор события формируется как test- и случайный UUID.
  • Отправка идёт через настоящий конвейер доставки: те же заголовки, та же подпись, те же таймауты и те же проверки адреса. Отличие одно — ровно одна попытка, без повторов.
  • Ответ 2xx даёт статус DELIVERED, любой другой — FAILED.
  • Доставка сохраняется в журнале с пометкой «тестовая» и видна в общем списке.
  • В ответе приходят идентификатор доставки, признак успеха, статус, причина и задержка в миллисекундах.
  • Если у подписки нет действующего секрета — 409 no_secret.

Тестовая отправка — единственный способ увидеть работающую доставку прямо сейчас, пока события продукта не публикуются.

Журнал доставок и статистика

Метод GET /webhook-service/api/v1/outbound-webhooks/{id}/deliveries.

ПараметрПо умолчаниюЧто делает
fromначало времёнНижняя граница периода, ISO-8601
toтекущий моментВерхняя граница периода, ISO-8601
statusне заданФильтр по статусу доставки
limit100Сколько строк вернуть. Максимум — 500, значения выше обрезаются

Строки отсортированы от новых к старым. Фильтр по статусу применяется до отсечения по limit, поэтому лимит отсчитывается уже среди доставок нужного статуса.

Вместе со списком приходит статистика по всей подписке — без учёта выбранного периода: всего доставок, доставлено, отказов, исчерпано, в очереди, отправляется, всего попыток.

Журнал попыток

Метод GET /webhook-service/api/v1/deliveries/{id} возвращает одну доставку и полный список её HTTP-попыток. По каждой попытке видно:

Что видноЧто означает
Номер попыткиНумерация с единицы
Код ответаПусто, если ответа не было вообще
ПричинаОдно из значений таблицы причин сбоя
ЗадержкаСколько миллисекунд заняла попытка
IP получателяАдрес, с которым соединение действительно было установлено
Срез ответаНачало тела ответа получателя, максимум 512 символов
ВремяКогда попытка выполнялась

Тело самого события в этом ответе не отдаётся.

Журнал хранится 30 дней

Завершённые доставки — доставленные, отказавшие и исчерпанные — вместе со всеми их попытками удаляются через 30 дней. Очистка идёт раз в сутки. Незавершённые доставки не удаляются никогда.

Учитывайте это при работе с персональными данными: в журнале хранится тело события и фрагмент ответа вашего сервера.

Какие адреса получателя запрещены

Адрес проверяется дважды: при регистрации подписки и заново в момент каждого подключения. Вторая проверка закрывает подмену адреса после регистрации — хост, переехавший на внутренний IP, всё равно не получит соединения.

Запрещены:

  • любая схема, кроме https;
  • адрес «любой» — 0.0.0.0 и ::, а также сеть 0.0.0.0/8;
  • loopback — 127.0.0.0/8 и ::1;
  • link-local — 169.254.0.0/16, включая адрес облачных метаданных 169.254.169.254, и fe80::/10;
  • приватные сети — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16;
  • сеть операторского NAT — 100.64.0.0/10;
  • IPv6 ULA — fc00::/7;
  • multicast и широковещательный 255.255.255.255.

Адреса вида ::ffff:a.b.c.d разворачиваются и проверяются по полному набору правил; незнакомое семейство адресов запрещается по умолчанию.

Разница между двумя проверками важна: при регистрации адрес отклоняется, если запрещён хотя бы один его IP; при доставке запрещённые адреса просто отбрасываются, и соединение идёт на оставшиеся — а если не осталось ни одного, попытка падает с причиной blocked.

Ограничения

  • Не больше 10 подписок на проект.
  • Только HTTPS-адреса получателя, максимум 2048 символов.
  • Событие крупнее 256 КиБ не отправляется.
  • На ответ получателя — 3 секунды, редиректы не выполняются.
  • До 10 попыток на доставку; автоматического отключения подписки при серии провалов нет.
  • Адрес и источник событий после создания не меняются — только набор типов.
  • Журнал доставок хранится 30 дней.
  • Каталога типов событий продукта нет, события в этот контур пока не публикуются.
  • Подписка сравнивает тип события целиком: шаблонов и фильтров по содержимому события нет.

Кто что может

ДействиеМинимальная роль
Создать, изменить, приостановить, удалить подпискуРоли проекта здесь не действуют — нужен служебный токен
Прочитать журнал доставок и попытокТо же
Прав по проекту в управляющем API пока нет

Управляющее API проверяет только валидность служебного токена: роли проекта оно не разбирает и не проверяет, что подписка относится именно к вашему проекту. Пока это так, наружу API не выставляется, а раздел в интерфейсе появится только вместе с разграничением прав.

Вебхуки · Типы событий · Входящие вебхуки · Вебхуки на дашборде · REST API