📤 Исходящие вебхуки
Что это. Fixorix сам отправляет POST-запрос на ваш HTTPS-адрес, как только появляется событие подходящего типа. Запрос подписан, неудачные доставки повторяются, каждая попытка попадает в журнал.
Когда нужно. Когда ваша система должна реагировать на события платформы сразу, а не выяснять изменения регулярными запросами к REST API.
Подписки на вебхуки будут настраиваться в дашборде, но раздела «Вебхуки» там пока нет — как он будет устроен, описано на странице Вебхуки на дашборде. Кроме того, продукт пока не публикует события для вебхуков, и каталога типов событий нет.
Ниже описано, как устроена доставка. По этому описанию можно заранее подготовить сервер, который будет принимать вебхуки.
Подписка на события
Подписка — это связка «проект + адрес получателя + список типов событий».
| Поле | Обязательно | Описание |
|---|---|---|
| Адрес получателя | да | HTTPS-адрес, на который уходит POST. Максимум 2048 символов. Схема http отклоняется |
| Типы событий | нет | Список типов, на которые подписан вебхук. Подписка без типов создаётся, но не получит ни одной доставки, пока типы не добавят. Формат каждого типа — [A-Za-z0-9][A-Za-z0-9._:@-]{0,127} |
При создании подписки выдаётся секрет подписи — строка из 64 символов URL-safe base64. Им подписывается каждая доставка.
Правила
- Один адрес — одна подписка. Повторно добавить тот же адрес в проект нельзя: вернётся уже существующая подписка, дубль не появится, а секрет повторно не выдаётся.
- Лимит — 10 подписок на проект.
- Адрес после создания не меняется. Менять можно только набор типов: добавлять и снимать. Чтобы сменить адрес, подписку удаляют и заводят заново.
- У каждой подписки свой набор типов. Одно событие может породить несколько доставок — по одной на каждую подписку проекта, подписанную на этот тип.
Адрес получателя не будет принят, если:
- он начинается не с
https://; - его не удаётся разобрать или в нём нет хоста;
- имя хоста не резолвится;
- хотя бы один IP-адрес хоста попадает в запрещённые диапазоны;
- он длиннее 2048 символов.
Секрет подписи выдаётся ровно один раз — при создании подписки. Посмотреть его позже нельзя. Потерянный секрет восстанавливается только обновлением — оно выдаёт новый.
Формат доставки
| Параметр | Значение | Что делает |
|---|---|---|
| Метод | POST | Другой метод не используется |
| Схема | только https | Доставка по http невозможна |
Content-Type | application/json | Тело всегда 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. Именно она вошла в подпись. Формируется заново при каждой попытке, поэтому повторная доставка приходит со свежей меткой. Время возникновения события — в поле createdAt конверта, если отправитель его задал |
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
- Python
- Java
const crypto = require("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 — тело запроса строкой или Buffer, ДО разбора JSON.
// Подпись = hex(HMAC-SHA256(секрет, X-Webhook-Timestamp + "." + сырое тело)), без префикса.
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.toString("utf8"), "utf8")
.digest("hex");
// в окне ротации секрета подходящей может оказаться любая из двух подписей
return safeEqual(expected, headers["x-signature"] || "")
|| safeEqual(expected, headers["x-signature-secondary"] || "");
}
import hashlib
import hmac
import math
import time
def safe_equal(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
# body — сырое тело запроса (bytes), ДО разбора JSON.
# headers — заголовки запроса без учёта регистра имён (например, request.headers во Flask).
# Подпись = hex(HMAC-SHA256(секрет, X-Webhook-Timestamp + "." + сырое тело)), без префикса.
def verify(body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Webhook-Timestamp")
if not timestamp:
return False
# допуск по времени выбираете вы сами, здесь — 5 минут
try:
age = abs(math.floor(time.time()) - float(timestamp))
except ValueError:
return False
if not math.isfinite(age) or age > 300:
return False
expected = hmac.new(
key=secret.encode("utf-8"),
msg=timestamp.encode("utf-8") + b"." + body,
digestmod=hashlib.sha256,
).hexdigest()
# в окне ротации секрета подходящей может оказаться любая из двух подписей
return safe_equal(expected, headers.get("X-Signature") or "") \
or safe_equal(expected, headers.get("X-Signature-Secondary") or "")
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.function.Function;
public class WebhookVerifier {
private static boolean safeEqual(String a, String b) {
return MessageDigest.isEqual(
a.getBytes(StandardCharsets.UTF_8),
b.getBytes(StandardCharsets.UTF_8)
);
}
// body — сырое тело запроса, ДО разбора JSON.
// header — «имя заголовка → значение или null», например request::getHeader.
// Подпись = hex(HMAC-SHA256(секрет, X-Webhook-Timestamp + "." + сырое тело)), без префикса.
public static boolean verify(byte[] body, Function<String, String> header, String secret) throws Exception {
var timestamp = header.apply("X-Webhook-Timestamp");
if (timestamp == null || timestamp.isEmpty()) return false;
// допуск по времени выбираете вы сами, здесь — 5 минут
long age;
try {
age = Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp));
} catch (NumberFormatException e) {
return false;
}
if (age > 300) return false;
var mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
var digest = mac.doFinal(body);
var hex = new StringBuilder();
for (var b : digest) {
hex.append(String.format("%02x", b));
}
var expected = hex.toString();
// в окне ротации секрета подходящей может оказаться любая из двух подписей
var primary = header.apply("X-Signature");
var secondary = header.apply("X-Signature-Secondary");
return safeEqual(expected, primary != null ? primary : "")
|| safeEqual(expected, secondary != null ? secondary : "");
}
}
Что должен делать получатель
| Правило | Зачем |
|---|---|
| Сравнивать подписи константным временем | Обычное сравнение строк выдаёт длину совпадающего префикса |
Отклонять слишком старую X-Webhook-Timestamp | Защита от повторной отправки перехваченного запроса |
Дедуплицировать по X-Webhook-Id | Доставка гарантируется «хотя бы один раз»: один и тот же идентификатор может прийти дважды |
| В окне обновления секрета принимать любую из двух подписей | Позволяет обновить секрет без потери валидных доставок |
| Отвечать быстро, работу делать асинхронно | На ответ отводится 3 секунды |
Возьмите сырое тело, приклейте к нему спереди метку времени и точку, посчитайте HMAC-SHA256 своим секретом и сравните с X-Signature — или с X-Signature-Secondary, если она пришла.
Обновление секрета
Обновление выдаёт новый секрет подписи. Порядок такой:
- Прежний запасной секрет, если он был, удаляется.
- Действующий секрет становится запасным и получает срок жизни — 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 | Обрыв или ошибка чтения-записи |
Пауза и возобновление
Подписку можно поставить на паузу и потом возобновить.
Приостановленная подписка продолжает набирать доставки: события по-прежнему становятся строками в очереди со статусом PENDING, просто отправка не выполняется. После возобновления накопленная очередь начнёт доставляться. Если получатель лежит долго, к моменту возобновления очередь может оказаться большой.
Тестовая отправка
Тестовое событие проверяет связку целиком, не дожидаясь настоящих событий.
- Тип события по умолчанию —
webhook.test. - Нагрузка кладётся в поле
bodyконверта; по умолчанию —{"test": true}. Пример тела:{"id":"test-…","projectId":"…","type":"webhook.test","createdAt":"…","body":{"test":true}}. - Идентификатор события формируется как
test-и случайный UUID. - Тестовая отправка работает и для подписки на паузе.
- Отправка идёт тем же путём, что и настоящие доставки: те же заголовки, та же подпись, те же таймауты и те же проверки адреса. Отличие одно — ровно одна попытка, без повторов.
- Ответ
2xxдаёт статусDELIVERED, любой другой —FAILED. - Доставка сохраняется в журнале с пометкой «тестовая» и видна в общем списке.
- Результат показывается сразу: успех или нет, статус, причина и задержка в миллисекундах.
- Если у подписки нет действующего секрета, тестовая отправка не выполняется.
Журнал доставок и статистика
Журнал показывает доставки подписки от новых к старым, с фильтрами по периоду и по статусу.
Вместе со списком видна статистика по всей подписке — без учёта выбранного периода: всего доставок, доставлено, отказов, исчерпано, в очереди, отправляется, всего попыток.
Журнал попыток
По каждой доставке виден полный список её 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 дней.
- Каталога типов событий продукта нет, события для вебхуков пока не публикуются.
- Подписка сравнивает тип события целиком: шаблонов и фильтров по содержимому события нет.
→ Вебхуки · Типы событий · Входящие вебхуки · Вебхуки на дашборде · REST API