📤 Исходящие вебхуки
Что это. Fixorix сам отправляет POST-запрос на ваш HTTPS-адрес, как только появляется событие подходящего типа. Запрос подписан, неудачные доставки повторяются, каждая попытка попадает в журнал.
Когда нужно. Когда ваша система должна реагировать на события платформы сразу, а не выяснять изменения регулярными запросами к REST API.
Подписаться можно на любой тип события, но списка типов, которые публикует сам продукт, не существует: тип события — произвольная строка, её задаёт та система, которая пишет событие. Ни один сервис Fixorix пока в этот контур события не публикует, поэтому реальных доставок по подписке сейчас не будет — кроме тестовой отправки.
Где находится
Раздела вебхуков в интерфейсе нет. Подписки заводятся через управляющее API сервиса вебхуков — базовый путь /webhook-service/api/v1/.
Все методы управления требуют заголовок 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-Type | application/json | Тело всегда JSON |
| Тело | весь конверт события, байт в байт | Отправляется целиком, а не только полезная нагрузка |
| Таймаут ответа | 3 секунды | Не успели ответить — попытка считается неудачной |
| Таймаут соединения | 1 секунда | Столько же отводится на получение соединения из пула |
| Редиректы | отключены | Ответ 3xx — терминальный отказ, переход по Location не выполняется |
| Cookie | не хранятся | Сессию на стороне получателя держать не получится |
| Максимальный размер | 262 144 байта (256 КиБ) | Событие крупнее не отправляется вовсе |
User-Agent | Fixorix-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-Id | UUID проекта |
X-Webhook-Timestamp | Метка времени в секундах epoch. Именно она вошла в подпись |
X-Signature | HMAC-SHA256 текущим действующим секретом, hex в нижнем регистре |
X-Signature-Secondary | Вторая подпись прежним секретом. Приходит только в окне ротации |
User-Agent | Fixorix-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 возвращает новый секрет. Порядок такой:
- Прежний запасной секрет, если он был, удаляется.
- Действующий секрет становится запасным и получает срок жизни — 86 400 секунд, то есть сутки.
- Создаётся новый действующий секрет и возвращается в ответе.
Сутки после ротации каждая доставка подписывается дважды: X-Signature — новым секретом, X-Signature-Secondary — прежним. За это окно получателю нужно подставить новый секрет у себя. По истечении окна прежний секрет перестаёт использоваться, и приходит только одна подпись.
Одновременно действующих секретов не бывает больше двух.
Статусы доставки
| Статус | Что означает |
|---|---|
PENDING — в очереди | Ждёт отправки или следующей попытки. В этом же статусе копятся доставки, пока подписка на паузе |
IN_PROGRESS — отправляется | Попытка выполняется прямо сейчас |
DELIVERED — доставлено | Получен ответ 2xx |
FAILED — отказ | Терминальный отказ, повторов не будет |
EXHAUSTED — исчерпана | Закончились все попытки |
Доставка, зависшая в статусе IN_PROGRESS дольше минуты, автоматически возвращается в очередь.
Повторные попытки
Максимум — 10 попыток на одну доставку.
| Попытка | Через сколько после предыдущей |
|---|---|
| 1 | сразу |
| 2 | 10 секунд |
| 3 | 30 секунд |
| 4 | 1 минута |
| 5 | 2 минуты |
| 6 | 5 минут |
| 7 | 10 минут |
| 8 | 30 минут |
| 9 | 1 час |
| 10 | 2 часа |
Если получатель недоступен всё это время, последняя попытка приходится примерно на 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 | не задан | Фильтр по статусу доставки |
limit | 100 | Сколько строк вернуть. Максимум — 500, значения выше обрезаются |
Строки отсортированы от новых к старым. Фильтр по статусу применяется до отсечения по limit, поэтому лимит отсчитывается уже среди доставок нужного статуса.
Вместе со списком приходит статистика по всей подписке — без учёта выбранного периода: всего доставок, доставлено, отказов, исчерпано, в очереди, отправляется, всего попыток.
Журнал попыток
Метод GET /webhook-service/api/v1/deliveries/{id} возвращает одну доставку и полный список её HTTP-попыток. По каждой попытке видно:
| Что видно | Что означает |
|---|---|
| Номер попытки | Нумерация с единицы |
| Код ответа | Пусто, если ответа не было вообще |
| Причина | Одно из значений таблицы причин сбоя |
| Задержка | Сколько миллисекунд заняла попытка |
| IP получателя | Адрес, с которым соединение действительно было установлено |
| Срез ответа | Начало тела ответа получателя, максимум 512 символов |
| Время | Когда попытка выполнялась |
Тело самого события в этом ответе не отдаётся.
Завершённые доставки — доставленные, отказавшие и исчерпанные — вместе со всеми их попытками удаляются через 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 не выставляется, а раздел в интерфейсе появится только вместе с разграничением прав.
→ Вебхуки · Типы событий · Входящие вебхуки · Вебхуки на дашборде · REST API