Skip to content

Вебхуки

Всё остальное в этой библиотеке зовёт Jira. Вебхук — это Jira зовёт вас: вы регистрируете URL, на сайте что-то происходит, и на ваш сервер приходит POST. Звать тут нечего и клиент строить не нужно — не хватало только формы того, что приходит, и jira.js/webhooks — это она.

typescript
import type { WebhookHeaders, WebhookPayload } from 'jira.js/webhooks';

app.post('/jira', (request, response) => {
  const headers = request.headers as WebhookHeaders;
  const payload = request.body as WebhookPayload;

  switch (payload.webhookEvent) {
    case 'jira:issue_created':
      console.log(payload.issue.key, 'создал', payload.user?.displayName);
      break;

    case 'sprint_started':
      console.log(payload.sprint?.name, 'начался');
      break;
  }

  response.sendStatus(200);
});

Подпуть содержит только типы. Он компилируется в export {}, ничего не добавляет к бандлу и одинаково работает с Express, Fastify, Hono, обработчиком Lambda и голым node:http.

Тело

WebhookPayload — объединение, размеченное по webhookEvent, так что switch сужает каждую ветку ровно до одного тела. Разберите все случаи, и default сузится до never — так необработанное событие становится ошибкой компиляции:

typescript
default: {
  const unhandled: never = payload;

  throw new Error(`необработанное событие вебхука: ${JSON.stringify(unhandled)}`);
}

Пятьдесят семь событий в шестнадцати группах: задача, свойство задачи, ворклог, комментарий, вложение, связь между задачами, тип задачи, проект, версия, фильтр, пользователь, семь общесайтовых переключателей option_*, спринт, доска, два отказа приложению в доступе и несработавшее выражение Jira. У каждой группы есть свой экспортируемый тип — IssueWebhookPayload, SprintWebhookPayload и так далее, — если нужно назвать его напрямую.

В любом теле есть три поля:

timestampкогда Jira подняла событие, в миллисекундах от эпохи
webhookEventсамо событие, и поле, по которому идёт switch
matchedWebhookIdsкаким регистрациям ответила эта доставка — только у вебхуков, зарегистрированных через REST

Что задокументировано, а что нет

Стоит знать, прежде чем доверять полю: Atlassian публикует одно полное тело — для событий задачи. Про остальные сказано лишь, что колбэк несёт «информацию о сущности, связанной с событием».

Поэтому тело события задачи здесь написано по этому примеру и по захвату настоящей доставки. Это единственная группа с обязательной сущностью:

typescript
case 'jira:issue_updated':
  payload.issue;                   // Issue — есть всегда
  payload.issue_event_type_name;   // 'issue_updated', 'issue_commented', 'issue_generic'…
  payload.changelog;               // что изменилось
  payload.comment;                 // заполнено, когда правкой был комментарий
  break;

issue_event_type_name подробнее, чем webhookEvent: правка, комментарий и переход по статусу приходят одинаково — как jira:issue_updated — и различаются только там. Типизировано обычной строкой намеренно: администратор сайта может добавить свои события задачи, так что множество не закрыто.

Все остальные группы называют свою сущность необязательным полем, по имени сущности, к которой относится событие, а не по спецификации: sprint у события спринта, board у события доски, worklog, attachment, project, version, filter. Сверить их с документацией Atlassian было не с чем, поэтому тип заставляет вас проверить, и в объявлении это написано прямо — видно при наведении.

Заголовки

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

заголовок
x-atlassian-webhook-identifierуникален для доставки в пределах сайта и не меняется при повторах — записывайте его, чтобы узнавать уже обработанный вебхук
x-atlassian-webhook-flowPrimary — само событие, тридцать секунд; Secondary — последствия массовой или каскадной операции, пятнадцать минут
x-atlassian-webhook-retryсколько было повторов; при первой попытке отсутствует
x-atlassian-webhook-traceто, что Connect-приложение приложило к запросу, вызвавшему событие
x-hub-signaturesha256=…, только у вебхука, зарегистрированного с секретом — передайте в verifyWebhookSignature

Удаление задачи лучше всего показывает смысл заголовка потока: jira:issue_deleted уходит как Primary, а все зависимые comment_deleted, attachment_deleted и issuelink_deleted — следом как Secondary, возможно через несколько минут.

Разбора здесь нет

Приведения типов выше — и есть интерфейс, намеренно. Тело вебхука определяется сайтом, который его послал: пользовательские поля под сгенерированными ключами в issue.fields, что угодно от установленного приложения, релиз Data Center, отличающийся от Cloud. Схема, достаточно строгая, чтобы её стоило иметь, отвергала бы тела, совершенно правильные где-то ещё. В остальной библиотеке ответ валидируется, потому что API его описывает; здесь описывать нечем.

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

x-hub-signature — единственное, что доказывает, что запрос пришёл от Jira, а не от того, кто нашёл ваш URL. Вебхук, зарегистрированный с секретом, несёт её как sha256=<hex> — HMAC-SHA256 по точным байтам тела.

ts
import express from 'express';
import { verifyWebhookSignature, type WebhookPayload } from 'jira.js/webhooks';

app.post('/jira', express.raw({ type: 'application/json' }), async (request, response) => {
  const trusted = await verifyWebhookSignature({
    body: request.body,
    secret: process.env.JIRA_WEBHOOK_SECRET!,
    signature: request.get('x-hub-signature'),
  });

  if (!trusted) return response.sendStatus(401);

  const payload = JSON.parse(request.body.toString()) as WebhookPayload;

  response.sendStatus(200);
});

Телом должны быть пришедшие байты. Именно здесь проверка обычно и ломается: express.json() и всё ему подобное отдают разобранный объект, а JSON.stringify от него — уже другая последовательность байтов для тех же данных: порядок ключей, пробелы и запись чисел не сохраняются, и подпись не совпадёт никогда. Возьмите то, что ваш фреймворк называет сырым телом.

Ответ — false на любой способ доставке оказаться недостоверной: заголовка нет, алгоритм не sha256, дайджест не шестнадцатеричный, дайджест нужной формы и неверного значения. Ваша реакция на все четыре одинакова, а различать их означало бы различать их и для того, кто прощупывает эндпоинт. Исключение бросается только на пустой секрет — это ошибка ваша, а не провалившаяся проверка.

Сравнение идёт за постоянное время, и ради всего этого ничего не импортируется: crypto.subtle — глобальный объект и в Node, и в браузере, так что сабпать по-прежнему ничего не добавляет к браузерной сборке.

Как зарегистрировать

Два способа, и ведут они себя по-разному:

  • Страница администратора, https://your-domain.atlassian.net/plugins/servlet/webhooks. То, что обычно и называют вебхуком в Jira. Регистрирует человек, живёт, пока кто-нибудь не удалит.
  • REST API, POST /rest/api/3/webhook — только для Connect- и OAuth 2.0-приложений, и регистрация протухает через тридцать дней, если её не продлить через refreshWebhooks. Именно эти доставки несут matchedWebhookIds, и библиотека покрывает нужные эндпоинты: jira.webhooks.registerDynamicWebhooks, getDynamicWebhooksForApp, refreshWebhooks, deleteWebhookById.

Справочник самого Atlassian — Webhooks на платформе Jira Cloud.