Вебхуки
Вебхуки работают в обе стороны:
- входящие — HTTP-запросы от внешних систем, которые от имени API-ключа выполняют операции в рабочем пространстве: создают и обновляют задачи, комментируют их, списывают время, публикуют сообщения в чат, создают и обновляют документы. Систему, которая не умеет собирать наше тело запроса, можно подключить как есть — её собственное тело разберут правила;
- исходящие — HTTP-запросы, которые «Интабия Платформа» сама отправляет вам, когда в рабочем пространстве что-то произошло.
Оба вида настраиваются в Настройки → API + Webhooks. Там же есть раздел Примеры: выбираете операцию или событие и реальный объект, и получаете готовое тело запроса с подставленными настоящими идентификаторами.

Входящие вебхуки
Заголовок раздела «Входящие вебхуки»Формат запроса
Заголовок раздела «Формат запроса»Есть два адреса для одного и того же вызова.
Основной — ключ в заголовке Authorization:
curl -X POST 'https://<host>/_webhook/api/v1/webhook/action' \ -H 'Authorization: Bearer fus_myworkspace_7f3a9c1d2e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: 3f29a1d2-6b1a-4e51-9b0a-6a1c9d8e7f21' \ -d '{ "action": "issue:create", "space": "FUSIO", "title": "Sync failing for tenant X", "description": "Steps to reproduce:\n1. Open integration settings\n2. Click Sync", "assignee": "dev@example.com", "priority": "high" }'Второй — тот же ключ прямо в пути, для отправителей (например, некоторых no-code сервисов), которые умеют настраивать только URL, без собственных заголовков:
curl -X POST 'https://<host>/_webhook/api/v1/webhook/k/fus_myworkspace_7f3a9c1d2e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8' \ -H 'Content-Type: application/json' \ -d '{"action": "chat:post", "space": "64f10a1b2c3d4e5f6a7b8c90", "message": "Deploy finished"}'Ключ в пути целиком попадает в access-логи прокси и балансировщиков — используйте этот вариант, только когда заголовок недоступен. У него и лимит частоты запросов строже (см. ниже).
Рабочего пространства в адресе нет: его определяет сам ключ. Точные адреса вашей установки показаны в Настройки → API + Webhooks → Примеры → Входящий запрос, оттуда их можно скопировать.

Тело запроса — всегда JSON вида { "action": "...", "<поле цели>": "...", ...поля операции }. Поле action определяет, какая операция выполняется, а цель операции передаётся в одном из трёх полей, своём для каждой операции: space (проект, канал или тимспейс), issue (задача) или document (документ). Какое поле у какой операции — в таблице ниже.
Операции
Заголовок раздела «Операции»action | Что делает | Обязательные поля | Необязательные поля |
|---|---|---|---|
issue:create | Создаёт задачу | space — идентификатор проекта (например, FUSIO); title | description (markdown), assignee (email), priority, estimation (число), parent (идентификатор родительской задачи) |
issue:update | Обновляет задачу | issue — идентификатор задачи (например, FUSIO-42) | title, description, priority, assignee (email или null, чтобы снять исполнителя), status (имя статуса) |
issue:comment | Комментирует задачу | issue — идентификатор задачи; message | — |
issue:time_report | Списывает время на задачу, а с полем id — правит уже списанное | issue — идентификатор задачи; для создания также employee (email), date (YYYY-MM-DD), hours (больше нуля) | description; id — идентификатор записи времени: если он передан, правятся только присланные поля этой записи |
chat:post | Публикует сообщение в канал | space — id канала; message | — |
doc:create | Создаёт документ | space — id тимспейса; title | content (markdown), parent (id родительского документа) |
doc:update | Обновляет документ | document — id документа | title, content |
Вместо description (у задач) и content (у документов) можно прислать поле body — тот же markdown. Передавать оба сразу нельзя, такой запрос отклоняется.
Как резолвятся ссылки:
- Проект (
issue:create) ищется по идентификатору проекта, а задача (issue:update,issue:comment,issue:time_report, а такжеparentуissue:create) — по своему идентификатору видаFUSIO-42. - Исполнитель (
assignee) ищется по email как верифицированный социальный идентификатор контакта. - Канал (
chat:post), тимспейс (doc:create) и документ (doc:update, а такжеparentуdoc:create) принимаются только как внутренний id: имя и заголовок можно переименовать, id — нельзя. Готовый id проще всего взять в Примеры → Входящий запрос, выбрав нужную цель. - Статус (
issue:update) ищется по имени без учёта регистра среди статусов, доступных типу задач проекта, которому принадлежит задача. - Запись времени (
issue:time_report):employeeищется по email, как иassignee;idпри правке сверяется с задачей изissue— чужую запись поправить нельзя. Идентификатор записи приходит в ответе на её создание (reportId) и в исходящем событииissue.time_reported.
Значения priority (без учёта регистра): urgent, high, medium, low, none (синоним — no_priority).
Ответ и статус задания
Заголовок раздела «Ответ и статус задания»Оба адреса приёма в случае успеха отвечают 202 и телом { "jobId": "wh_..." } — запрос принят и поставлен в очередь, дальнейшая обработка асинхронная.
Проверить статус задания:
curl 'https://<host>/_webhook/api/v1/webhook/job/wh_1a2b3c4d5e' \ -H 'Authorization: Bearer fus_myworkspace_7f3a9c1d2e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8'Ответ:
{ "jobId": "wh_1a2b3c4d5e", "status": "done", "createdAt": 1735689600000, "result": { "identifier": "FUSIO-142" }}status — queued, done или failed. result появляется только при done и содержит то, что вернула операция (например, identifier у issue:create/issue:update, messageId у issue:comment/chat:post, docId у doc:create/doc:update). error появляется только при failed и содержит текст причины. Для проверки статуса подходит любой действующий ключ этого же рабочего пространства, не обязательно тот, которым задание создавалось.
Информация о задании хранится примерно сутки — после этого GET job/{id} вернёт not_found, даже если задание было выполнено.
Коды ошибок
Заголовок раздела «Коды ошибок»Ответ с ошибкой — всегда JSON вида { "error": "<код>", "message": "<текст>" }.
error | HTTP | Что значит |
|---|---|---|
unauthorized | 401 | Ключ не передан, не найден, отозван, истёк, выдан для другого рабочего пространства или у него не включены входящие вебхуки. Ответ намеренно одинаковый для всех этих случаев, чтобы не подсказывать, какие ключи вообще существуют. |
forbidden | 403 | Ключ не даёт права на операцию из action — её нет в списке разрешённых при выпуске ключа. Тот же код — если цель запроса лежит вне пространств, которыми ограничен ключ. |
invalid_payload | 400 | В теле нет action или поля цели, либо action не входит в список известных операций. |
payload_too_large | 413 | Тело запроса больше 1 МБ. |
rate_limited | 429 | Превышен лимит частоты запросов. |
not_found | 404 | Цель запроса не найдена (нет такого проекта, задачи, канала, тимспейса или документа). Тот же код — если не найдено задание в GET job/{id} и для неизвестного пути. |
service_unavailable | 503 | Сервис аккаунтов временно недоступен, ключ проверить не удалось. Единственная ошибка приёма, которую имеет смысл повторить. |
internal_error | 500 | Внутренняя ошибка при приёме запроса. |
Проверки идут по порядку, и каждая следующая выполняется, только если пройдена предыдущая: лимит частоты по ключу → сам ключ → тело запроса и action → право на операцию → идемпотентность → поиск цели → постановка в очередь. Исключение — 413: слишком большое тело отклоняется до того, как запрос вообще доходит до этой цепочки.
Цель ищется до постановки в очередь намеренно: про опечатку в идентификаторе проекта отправитель узнаёт сразу из 404, а не из провалившегося задания через минуту. Если сам поиск не удался (например, транзактор недоступен), задание всё равно принимается, и цель проверяется уже при исполнении.
Частота запросов ограничена скользящим окном отдельно по ключу и отдельно по IP-адресу отправителя — сработавшим считается тот лимит, который исчерпался первым. Значения по умолчанию, запросов за 60 секунд:
- ключ в заголовке
Authorization— 60; - ключ в пути
/k/{key}— 20 (строже: такой ключ целиком утекает в логи); - IP-адрес — 60.
Лимит по ключу считается по предъявленному значению, ещё до его проверки. Лимит по IP-адресу расходуют только запросы с ключом, который проверку не прошёл: это защита от перебора, и действующий ключ её не тратит, даже если запросы идут с того же адреса, что и чужие неудачные.
При 429 в ответе есть заголовки:
Retry-After— через сколько секунд можно повторить попытку;Retry-After-ms— то же в миллисекундах;X-RateLimit-Limit— лимит запросов на текущее окно;X-RateLimit-Remaining— сколько запросов осталось (при429—0);X-RateLimit-Reset— момент, когда текущее окно обнулится, в миллисекундах Unix-времени (не в секундах).
Идемпотентность
Заголовок раздела «Идемпотентность»Заголовок Idempotency-Key — любое уникальное значение на ваш выбор (например, UUID события в вашей системе). Повторный запрос с тем же ключом от того же API-ключа не создаёт новое задание — в ответ снова приходит 202 с jobId первого запроса. Идемпотентность привязана к паре «API-ключ + значение Idempotency-Key» и хранится около суток; после этого повтор с тем же значением создаст новое задание.
Дедупликация не сверяет тело запроса — если отправить один и тот же Idempotency-Key с другим телом, в ответе всё равно вернётся jobId самого первого запроса, а не результат обработки нового тела. Выбирайте значение, уникальное именно для конкретного события.
Поведение при ошибках и повторах
Заголовок раздела «Поведение при ошибках и повторах»- Ошибки на входе (
401,403,400,404,413,429) — окончательны. Повторять тот же запрос без изменений бессмысленно: нужно исправить ключ, тело, права, цель или дождаться окончания окна лимита. Повторить стоит только503и500. - После
202обработка асинхронная. Если операция сама завершается постоянной ошибкой (например, невалидные ссылки в теле или ключу не хватает прав на нужное пространство), задание сразу помечаетсяfailed, без повторов — причина видна вerrorчерезGET job/{id}. Исключение — отказ по внутреннему лимиту частоты: такое задание повторяется по расписанию ниже. - Временные сбои (сеть, недоступность внутренних сервисов) обрабатываются повторами по расписанию: 30 секунд, 1, 2, 4, 8 минут — всего до 5 попыток. Если и после них операция не выполнилась, задание помечается
failedокончательно.
Правила для чужого формата
Заголовок раздела «Правила для чужого формата»Alertmanager, GitLab, Sentry и подобные системы шлют собственное тело запроса и не умеют собирать наше. Для них есть отдельные адреса, которые принимают тело отправителя как есть, а правила переводят его в операции Платформы:
curl -X POST 'https://<host>/_webhook/api/v1/webhook/in' \ -H 'Authorization: Bearer fus_myworkspace_7f3a9c1d2e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8' \ -H 'Content-Type: application/json' \ -d '{ ...тело вашей системы... }'Есть и вариант с ключом в пути — POST /_webhook/api/v1/webhook/k/{key}/in, с теми же оговорками и тем же лимитом, что у /k/{key}.
Где настраивать
Заголовок раздела «Где настраивать»Правила живут на конкретном API-ключе. В Настройки → API + Webhooks у строки ключа есть кнопка Правила; она появляется, только если ключ не отозван, у него включены входящие вебхуки и вы сами его выпустили. Чужие ключи и чужие правила не видны, даже владельцу рабочего пространства.

Правила проверяются сверху вниз, порядок меняется перетаскиванием. Каждое можно временно выключить переключателем, не удаляя. Правки применяются в течение примерно десяти секунд: сервис держит правила в кэше.
Из шаблона
Заголовок раздела «Из шаблона»Быстрый путь — Из шаблона. Готовые шаблоны: Prometheus Alertmanager, Grafana, GitLab (push и pipeline), Sentry. Вы выбираете только операцию и цель — например, «публиковать в канал general», — а условия, поля и пример тела уже заполнены.

После создания показывается адрес, который надо прописать в отправляющей системе. Ключ передаётся как Authorization: Bearer <ключ>; у Alertmanager это http_config.authorization.credentials.

Как устроено правило
Заголовок раздела «Как устроено правило»
- Пример тела запроса — JSON, который присылает ваша система. Он нужен только в редакторе: из него собираются списки путей для условий и полей, и по нему же считается предпросмотр.
- Условия — проверки по всему телу, все должны выполниться. Операторы: равно, не равно, существует, соответствует регулярному выражению, одно из (список значений через запятую).
- Для каждого — путь до массива в теле. Правило отработает по одному разу на элемент массива: в одном оповещении Alertmanager приходит сразу несколько алертов.
- Условие элемента — дополнительная проверка для каждого элемента, пути считаются от самого элемента. Ровно так разделяются
firingиresolvedв одной группе. - Операция и Цель — что и где сделать. Выбор ограничен операциями, разрешёнными этому ключу.
- Поля — поля операции, собранные из шаблона:
{{labels.alertname}}— путь от текущего элемента (или от корня тела, если цикла нет),{{$.externalURL}}— всегда от корня. Кнопка Вставить значение подставляет путь из примера тела. - Предпросмотр показывает, совпало ли правило, какое значение нашлось по каждому условию и что получится в полях.
Успешный ответ — 202 со списком созданных заданий:
{ "jobs": [{ "jobId": "wh_1a2b3c4d5e", "rule": "6ab2be02925a16ab365dcd84" }], "skipped": []}Статус каждого задания проверяется через GET job/{id}, как и у обычных входящих запросов.
- Если не совпало ни одно правило, ответ тоже
202, но с пустымjobs. Для отправителя это успех: он не должен повторять запрос из-за того, что событие вам неинтересно. - В
skippedпопадают правила, которые совпали, но выполнить их нельзя:forbidden— операция или цель вне прав ключа,not_found— цель удалена или переименована. Права правилом не расширяются: правило может только то же, что и сам ключ. - Один запрос даёт не больше 50 заданий. Если совпавшие правила вместе дают больше, весь запрос отклоняется с
413, в очередь не попадает ничего. - Заголовок
Idempotency-Keyна этих адресах не поддерживается: один запрос превращается в несколько заданий, и сопоставить их с одним ключом нечем.
Регулярные выражения из условий выполняются с жёстким ограничением по времени, а слишком длинный шаблон (больше 200 символов) или слишком длинное проверяемое значение (больше 2000 символов) считаются несовпадением. Это защита от выражений, на которых проверка зависает.
Исходящие вебхуки
Заголовок раздела «Исходящие вебхуки»Исходящий вебхук — это адрес, на который Платформа отправляет POST с описанием события. Настраиваются в Настройки → API + Webhooks → Исходящие вебхуки; раздел доступен только владельцу рабочего пространства.
При добавлении получателя указываются URL и набор событий:

Дальше у адреса есть: переключатель вкл/выкл, список «Где действует», секреты подписи, журнал последних доставок и кнопка тестовой отправки.

События
Заголовок раздела «События»| Тип | Когда отправляется |
|---|---|
issue.created | Создана задача |
issue.status_changed | Изменён статус задачи |
issue.assigned | Изменён исполнитель задачи |
issue.commented | К задаче добавлен комментарий |
issue.time_reported | На задачу списано время |
issue.time_report_updated | Запись о списанном времени изменена |
message.posted | Опубликовано сообщение в канале |
document.created | Создан документ |
Тело события
Заголовок раздела «Тело события»Готовый пример с настоящими идентификаторами собирается в Примеры → Исходящее событие:

{ "action": "update", "type": "issue.status_changed", "actor": "author@example.com", "actorUrl": "https://fusion.example.com/workbench/acme/contact/64f10a1b2c3d4e5f6a7b8c8f", "url": "https://fusion.example.com/workbench/acme/tracker/FUSIO-123", "data": { "id": "64f10a1b2c3d4e5f6a7b8c91", "identifier": "FUSIO-123", "status": "In Progress" }, "updatedFrom": { "status": "Todo" }, "webhookId": "msg_2f1c9a7b", "webhookTimestamp": 1789084800, "organizationId": "9c858f36-6b1a-4d3a-8f2e-1a2b3c4d5e6f"}action—create,updateилиremove;type— тип события из таблицы выше;actor— email автора изменения,actorUrl— ссылка на его карточку;url— ссылка на сам объект: задачу, документ или канал;data— состояние объекта,updatedFrom— прежние значения изменённых полей (только уupdate);webhookId,webhookTimestamp— идентификатор и время доставки, они же участвуют в подписи;organizationId— идентификатор рабочего пространства.
Значения в событии — те же, что принимает входящий API, чтобы объект можно было сразу отправить обратно: статус приходит именем (In Progress), исполнитель и автор — email, приоритет — именем (urgent, high, medium, low, no_priority), канал — своим id в data.channel (именно его принимает chat:post, имя лежит рядом, в data.channelName), родительская задача комментария или записи времени — идентификатором в data.issue. Если значение разрешить не удалось, в поле останется внутренний идентификатор.
Подпись
Заголовок раздела «Подпись»Доставка подписывается по схеме Standard Webhooks. Заголовки:
| Заголовок | Значение |
|---|---|
webhook-id | Идентификатор доставки |
webhook-timestamp | Время отправки, Unix-время в секундах |
webhook-signature | Подписи через пробел, каждая вида v1,<base64> |
X-Webhook-Delivery-Id | Тот же идентификатор доставки, для журналов |
X-Webhook-Attempt | Номер попытки, начиная с 0 |
Проверка: берётся строка {webhook-id}.{webhook-timestamp}.{тело запроса}, считается HMAC-SHA256 с ключом — это байты секрета после префикса whsec_, декодированные из base64 — и результат в base64 сравнивается с одной из подписей в заголовке.
import base64, hashlib, hmac
def verify(secret, headers, raw_body): key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.{raw_body}".encode() expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() return any( hmac.compare_digest(expected, part.split(",", 1)[1]) for part in headers["webhook-signature"].split(" ") )Считайте подпись по сырому телу запроса, до разбора JSON: пересобранный из объекта JSON почти наверняка отличается пробелами и порядком ключей, и подпись не сойдётся. Проверяйте и webhook-timestamp — доставку старше нескольких минут разумно отклонять.
Ротация секрета
Заголовок раздела «Ротация секрета»Секретов у адреса может быть два одновременно, и каждой доставкой подписываются оба — поэтому заголовок webhook-signature может содержать несколько подписей. Порядок смены секрета без простоя:
- добавить второй секрет — приёмник продолжает проверять старым;
- переключить приёмник на новый;
- вывести старый из ротации.
Последний секрет удалить нельзя: без секрета подпись не строится.
Где действует
Заголовок раздела «Где действует»Пустой список означает «все проекты, каналы и папки, кроме приватных»: событие из приватного проекта или канала уходит наружу, только если он назван в списке явно. Так адрес, настроенный владельцем рабочего пространства, не выносит наружу содержимое приватных мест, в которых сам владелец не состоит.
Повторы и отключение
Заголовок раздела «Повторы и отключение»- Повтор делается на кодах
408,409,425,429и любых5xx, а также на сетевых ошибках и таймауте (10 секунд на попытку). - Расписание: 30 секунд, 1, 2, 4, 8 минут — всего до 5 попыток; после этого доставка считается неудачной.
- Три неудачные доставки подряд выключают адрес, владельцу рабочего пространства уходит письмо. Успешная доставка обнуляет счётчик.
- Ответ приёмника читается до 64 КБ, тело ответа не используется — важен только код.
Требования к адресу
Заголовок раздела «Требования к адресу»- Только
https. Приём поhttpвозможен лишь на стенде, где это включено настройкой пода. - Приватные и служебные адреса запрещены:
127.0.0.0/8,10/8,172.16/12,192.168/16,169.254/16,100.64/10,0/8,224/4, их IPv6-аналоги, а также именаlocalhost,metadata,*.internal,*.local,*.cluster.local. Имя проверяется дважды: перед запросом и непосредственно перед соединением, чтобы адрес нельзя было подменить после проверки. - Редиректы не выполняются: ответ
3xxсчитается неуспешной доставкой.
Если события не приходят
Заголовок раздела «Если события не приходят»- Адрес выключен — проверьте переключатель рядом с URL и поле «последняя ошибка» на странице адреса.
- Событие не отмечено в списке событий адреса.
- Событие произошло в приватном проекте или канале, которого нет в списке «Где действует».
- Подпись не сходится — почти всегда из-за того, что она считается по пересобранному JSON, а не по сырому телу.
- Ничего не приходило вообще — нажмите «Отправить тестовое событие» на странице адреса: результат появится сразу, а сама доставка попадёт в журнал.
