Skip to content
Exlogare

Исходящие webhook'и

Отправляйте каждое событие analysis.completed на свой URL — PagerDuty, Sentry, Linear, Jira или собственный обработчик. Подписываются HMAC-SHA256, ретраи с backoff.

Исходящие webhook’и позволяют Exlogare отправлять каждый завершённый RCA на ваш URL. Это нужный инструмент когда:

  • Нужно открыть инцидент в PagerDuty при появлении high-severity падения.
  • Нужно автоматически создавать issue в Sentry, тикет в Linear или Jira.
  • Нужно прокидывать события в свой собственный relay, внутреннюю очередь или другой SaaS.

Исходящие webhook’и доступны на тарифе Startup и выше — тот же gate что у мессенджер-уведомлений. На Free вы можете отправлять логи и читать историю по API, но push наружу не работает.

Как это устроено

  1. Вы добавляете подписку в Settings → Outbound webhooks: имя, https://… URL и список событий.
  2. На каждое подходящее событие (сейчас это analysis.completed) Exlogare планирует доставку.
  3. Доставка делает POST с JSON-телом и подписанным заголовком X-Exlogare-Signature.
  4. Если ваш endpoint вернул 2xx — доставка успешна. Любой другой ответ ретрается с экспоненциальным backoff (30s, 1m, 2m, 4m, 8m, 15m).
  5. После 10 подряд неудачных доставок подписка автоматически отключается. Включить обратно — в Settings, после починки получателя.

Доставка идёт в фоне, поэтому медленный или упавший receiver никогда не блокирует анализ или fan-out в мессенджеры.

Payload

{
  "type": "analysis.completed",
  "id": "evt_2c1f5b4f-…",
  "delivered_at": "2026-04-26T10:00:00.000000+00:00",
  "tenant_id": "8d1c…",
  "analysis": {
    "id": "f8d7…",
    "provider": "github_actions",
    "source": "github_actions_ingest",
    "ci_run_id": "8421",
    "ci_job_id": "23170982",
    "project_path": "acme/api",
    "project_web_url": "https://github.com/acme/api",
    "pipeline_url": "https://github.com/acme/api/actions/runs/8421",
    "job_url": "https://github.com/acme/api/actions/runs/8421/jobs/23170982",
    "mr_iid": "412",
    "root_cause": "…",
    "explanation": "…",
    "fix_suggestion": "…",
    "severity": "high",
    "confidence": 0.91,
    "created_at": "2026-04-26T09:59:58+00:00"
  }
}

Поле analysis совпадает по форме с публичным Read API (/api/v1/analyses/{id}) — receiver может перезапросить полные данные без переименования полей.

Заголовки

ЗаголовокЧто значит
Content-Type: application/jsonВсегда JSON.
User-Agent: Exlogare-Webhook/1.0Удобно фильтровать на стороне receiver’а.
X-Exlogare-EventТип события (например, analysis.completed).
X-Exlogare-DeliveryИдентификатор доставки (равен id в payload). Используйте для дедупа ретраев.
X-Exlogare-Signaturet=<unix>,v1=<hex_hmac> — см. ниже.

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

Подпись — HMAC-SHA256 от строки "<unix_timestamp>.<raw_body_bytes>", в hex-кодировке. Та же схема, что у Stripe / Slack / GitHub.

import hmac, time
from hashlib import sha256

def verify(secret: str, header: str, body: bytes, max_age=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    ts = int(parts.get("t", "0"))
    provided = parts.get("v1", "")
    if abs(int(time.time()) - ts) > max_age:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + body, sha256).hexdigest()
    return hmac.compare_digest(provided, expected)

Готовый receiver-скрипт: /integrations/scripts/verify-webhook.py.

import crypto from "node:crypto";

export function verify(secret, header, body, maxAge = 300) {
  const parts = Object.fromEntries(
    header.split(",").filter(p => p.includes("=")).map(p => p.split("="))
  );
  const ts = Number.parseInt(parts.t || "0", 10);
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > maxAge) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.`)
    .update(body)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(parts.v1 || "", "hex"), Buffer.from(expected, "hex"));
}
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strconv"
    "strings"
    "time"
)

func Verify(secret, header string, body []byte, maxAge int64) bool {
    parts := map[string]string{}
    for _, kv := range strings.Split(header, ",") {
        if k, v, ok := strings.Cut(kv, "="); ok {
            parts[k] = v
        }
    }
    ts, _ := strconv.ParseInt(parts["t"], 10, 64)
    if abs(time.Now().Unix()-ts) > maxAge {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(parts["v1"]), []byte(expected))
}

Подпись обязательно проверяйте. Поле t защищает от replay-атак — отбрасывайте всё старше 5 минут.

Рецепты

PagerDuty Events API v2

Открывать инцидент в PagerDuty на каждое severity: high:

import os, json, requests
from your_app.exlogare_signature import verify

PD_KEY = os.environ["PAGERDUTY_ROUTING_KEY"]
EXL_SECRET = os.environ["EXLOGARE_WEBHOOK_SECRET"]

def handler(request_body: bytes, signature_header: str):
    if not verify(EXL_SECRET, signature_header, request_body):
        return 401
    payload = json.loads(request_body)
    if payload["type"] != "analysis.completed":
        return 200
    a = payload["analysis"]
    if a["severity"] != "high":
        return 200
    requests.post(
        "https://events.pagerduty.com/v2/enqueue",
        json={
            "routing_key": PD_KEY,
            "event_action": "trigger",
            "dedup_key": f"exlogare-{a['id']}",
            "payload": {
                "summary": f"[{a['severity']}] {a['root_cause'][:120]}",
                "severity": "error",
                "source": a.get("project_path", "exlogare"),
                "custom_details": {
                    "fix_suggestion": a.get("fix_suggestion"),
                    "ci_run_id": a.get("ci_run_id"),
                    "pipeline_url": a.get("pipeline_url"),
                },
            },
            "links": [{"href": a.get("pipeline_url"), "text": "CI run"}],
        },
        timeout=5,
    )
    return 200

dedup_key гарантирует, что ретраи не создают дубль-инциденты.

Sentry Issue API

Заводить каждое RCA как issue в Sentry-проекте:

def to_sentry(payload, dsn_token, project):
    a = payload["analysis"]
    requests.post(
        f"https://sentry.io/api/0/projects/{project}/store/",
        headers={"Authorization": f"DSN {dsn_token}"},
        json={
            "message": a["root_cause"],
            "level": {"low": "warning", "medium": "warning", "high": "error"}[a["severity"]],
            "extra": {
                "explanation": a["explanation"],
                "fix_suggestion": a["fix_suggestion"],
                "pipeline_url": a.get("pipeline_url"),
                "exlogare_id": a["id"],
            },
            "fingerprint": [a["root_cause"][:200]],
        },
        timeout=5,
    )

Linear / Jira

Для трекеров делайте дедуп по analysis.id плюс первые 80 символов root_cause, чтобы флапающие пайплайны не плодили по одному тикету на сборку:

title = f"[CI] {a['root_cause'][:80]}"
external_id = f"exlogare:{a['id']}"

# Linear: мутация createIssue с externalId
# Jira: POST /rest/api/3/issue с `properties: [{key: "exlogare-id", value: …}]`

Ручная переотправка

Receiver был недоступен час и вы не хотите ждать автоотключения? Откройте подписку в Settings → Outbound webhooks → нажмите Redeliver для нужного analysis id. Или из API:

curl -fsS -X POST -H "Cookie: session=…" \
  -H "Content-Type: application/json" \
  -d '{"analysis_id": "f8d7…"}' \
  https://app.exlogare.net/api/integrations/outbound-webhooks/<sub_id>/redeliver

При replay генерируется новый X-Exlogare-Delivery, но payload тот же — рецепт с dedup_key выше защитит от дубль-инцидентов.

API surface

Тот же UI доступен по /api/integrations/outbound-webhooks для скриптов:

МетодПутьНазначение
GET/api/integrations/outbound-webhooksСписок подписок.
POST/api/integrations/outbound-webhooksСоздать. Секрет показывается один раз.
PATCH/api/integrations/outbound-webhooks/{id}Включить/выключить, переименовать, поменять события.
POST/api/integrations/outbound-webhooks/{id}/rotate-secretНовый HMAC-секрет (показывается один раз).
POST/api/integrations/outbound-webhooks/{id}/testСинхронный тестовый POST — не влияет на счётчик ошибок.
POST/api/integrations/outbound-webhooks/{id}/redeliverПерепрослать существующий анализ.
DELETE/api/integrations/outbound-webhooks/{id}Удалить.

Эти endpoint’ы используют ту же cookie-аутентификацию что и dashboard (только admin). API-токены scope=read подходят только для Read API — управление webhook’ами идёт через cookie, чтобы long-lived токены не носили в себе секреты подписок.

Операционные заметки

  • Только https://. Plaintext receivers отвергаются, исключение — localhost для разработки.
  • Лимит: до 25 подписок на тенанта.
  • Timeout: 8 секунд на попытку. Медленный receiver = неудачная попытка = ретрай.
  • Авто-отключение: 10 подряд неудачных доставок (transport или не-2xx). Счётчик сбрасывается при первой успешной доставке; ручное включение тоже сбрасывает.
  • Никаких PII в заголовках сверх того, что уже в теле — receiver должен использовать body как источник истины.