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

Вебхуки

Вебхуки работают в обе стороны:

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

Оба вида настраиваются в Настройки → API + Webhooks. Там же есть раздел Примеры: выбираете операцию или событие и реальный объект, и получаете готовое тело запроса с подставленными настоящими идентификаторами.

Раздел 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); titledescription (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 тимспейса; titlecontent (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": "<текст>" }.

errorHTTPЧто значит
unauthorized401Ключ не передан, не найден, отозван, истёк, выдан для другого рабочего пространства или у него не включены входящие вебхуки. Ответ намеренно одинаковый для всех этих случаев, чтобы не подсказывать, какие ключи вообще существуют.
forbidden403Ключ не даёт права на операцию из action — её нет в списке разрешённых при выпуске ключа. Тот же код — если цель запроса лежит вне пространств, которыми ограничен ключ.
invalid_payload400В теле нет action или поля цели, либо action не входит в список известных операций.
payload_too_large413Тело запроса больше 1 МБ.
rate_limited429Превышен лимит частоты запросов.
not_found404Цель запроса не найдена (нет такого проекта, задачи, канала, тимспейса или документа). Тот же код — если не найдено задание в GET job/{id} и для неизвестного пути.
service_unavailable503Сервис аккаунтов временно недоступен, ключ проверить не удалось. Единственная ошибка приёма, которую имеет смысл повторить.
internal_error500Внутренняя ошибка при приёме запроса.

Проверки идут по порядку, и каждая следующая выполняется, только если пройдена предыдущая: лимит частоты по ключу → сам ключ → тело запроса и 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 может содержать несколько подписей. Порядок смены секрета без простоя:

  1. добавить второй секрет — приёмник продолжает проверять старым;
  2. переключить приёмник на новый;
  3. вывести старый из ротации.

Последний секрет удалить нельзя: без секрета подпись не строится.

Пустой список означает «все проекты, каналы и папки, кроме приватных»: событие из приватного проекта или канала уходит наружу, только если он назван в списке явно. Так адрес, настроенный владельцем рабочего пространства, не выносит наружу содержимое приватных мест, в которых сам владелец не состоит.

  • Повтор делается на кодах 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, а не по сырому телу.
  • Ничего не приходило вообще — нажмите «Отправить тестовое событие» на странице адреса: результат появится сразу, а сама доставка попадёт в журнал.
Журнал доставок и тестовая отправка