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