Outbound webhooks
Push every analysis.completed event to your own URL — PagerDuty, Sentry, Linear, Jira, or a custom relay. HMAC-SHA256 signed, retried with backoff.
Outbound webhooks let Exlogare push every completed RCA to a URL you control. They are the right tool when you want to:
- Open a PagerDuty incident the moment a high-severity failure hits.
- File a Sentry issue, Linear ticket, or Jira issue automatically.
- Forward to a custom relay, internal queue, or another SaaS.
Outbound webhooks are part of the Startup plan and above — same gate as messenger notifications. The Free plan can ingest logs and read history through the API, but it does not push events outbound.
How it works
- You add a subscription at Settings → Outbound webhooks: a name, an
https://…URL, and the events you want. - On every matching event (currently
analysis.completed), Exlogare schedules a delivery. - The delivery POSTs JSON to your URL with a signed
X-Exlogare-Signatureheader. - If your endpoint returns 2xx, we mark the delivery successful. Anything else is retried with exponential backoff (30s, 1m, 2m, 4m, 8m, 15m).
- After 10 consecutive failures the subscription is auto-disabled. Re-enable it in Settings after fixing the receiver.
Deliveries run out of band, so a slow or down receiver never blocks analysis or messenger 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 mirrors the public Read API (/api/v1/analyses/{id}), so receivers can pivot to a fresh fetch without translating field names.
Headers
| Header | Meaning |
|---|---|
Content-Type: application/json | Always JSON. |
User-Agent: Exlogare-Webhook/1.0 | Useful for receiver-side filtering. |
X-Exlogare-Event | Event type (e.g. analysis.completed). |
X-Exlogare-Delivery | Delivery id (matches id in the payload). Use it to dedupe retries. |
X-Exlogare-Signature | t=<unix>,v1=<hex_hmac> — see below. |
Verifying the signature
The signature is HMAC-SHA256 over "<unix_timestamp>.<raw_body_bytes>", hex-encoded. The same scheme as 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)
Drop-in receiver script: /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))
}
Always verify. The t timestamp guards against replay attacks; reject anything older than 5 minutes.
Recipes
PagerDuty Events API v2
Open a PagerDuty incident on every 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 ensures retries don’t open duplicate incidents.
Sentry Issue API
Push every RCA into a Sentry project as an issue:
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
For tracker-style integrations, deduplicate on analysis.id + the first 80 characters of root_cause so flapping CI runs don’t spawn one ticket per build:
title = f"[CI] {a['root_cause'][:80]}"
external_id = f"exlogare:{a['id']}"
# Linear: createIssue mutation with externalId
# Jira: POST /rest/api/3/issue with `properties: [{key: "exlogare-id", value: …}]`
Manual redelivery
Receiver was down for an hour and you don’t want to wait for the auto-disable to clear? Open the subscription in Settings → Outbound webhooks → click Redeliver on a specific analysis id. Or via 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
The replay carries a fresh X-Exlogare-Delivery id but the same payload, so the dedup_key recipe above keeps it from creating duplicate incidents.
API surface
The same UI lives at /api/integrations/outbound-webhooks for scripted use:
| Method | Path | Purpose |
|---|---|---|
GET | /api/integrations/outbound-webhooks | List subscriptions. |
POST | /api/integrations/outbound-webhooks | Create. Returns the secret once. |
PATCH | /api/integrations/outbound-webhooks/{id} | Toggle / rename / change events. |
POST | /api/integrations/outbound-webhooks/{id}/rotate-secret | New HMAC secret (returned once). |
POST | /api/integrations/outbound-webhooks/{id}/test | Synchronous test POST — does not affect the failure counter. |
POST | /api/integrations/outbound-webhooks/{id}/redeliver | Replay an existing analysis. |
DELETE | /api/integrations/outbound-webhooks/{id} | Remove. |
These endpoints use the same cookie-auth as the dashboard (admin-only). Use API tokens with scope=read only for the Read API surface — outbound webhook management stays cookie-bound to keep secrets out of long-lived integration tokens.
Operational notes
- Use
https://. Plaintext receivers are rejected, except forlocalhostduring development. - Tenant cap: up to 25 subscriptions per tenant.
- Timeout: 8 seconds per attempt. Slow receivers count as failed and trigger a retry.
- Auto-disable: 10 consecutive delivery failures (transport or non-2xx). Counter resets on the first success; a manual re-enable also clears it.
- No PII in headers beyond what’s already in the payload — receivers should treat the body as the source of truth.