Skip to content
Exlogare

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

  1. You add a subscription at Settings → Outbound webhooks: a name, an https://… URL, and the events you want.
  2. On every matching event (currently analysis.completed), Exlogare schedules a delivery.
  3. The delivery POSTs JSON to your URL with a signed X-Exlogare-Signature header.
  4. If your endpoint returns 2xx, we mark the delivery successful. Anything else is retried with exponential backoff (30s, 1m, 2m, 4m, 8m, 15m).
  5. 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

HeaderMeaning
Content-Type: application/jsonAlways JSON.
User-Agent: Exlogare-Webhook/1.0Useful for receiver-side filtering.
X-Exlogare-EventEvent type (e.g. analysis.completed).
X-Exlogare-DeliveryDelivery id (matches id in the payload). Use it to dedupe retries.
X-Exlogare-Signaturet=<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:

MethodPathPurpose
GET/api/integrations/outbound-webhooksList subscriptions.
POST/api/integrations/outbound-webhooksCreate. Returns the secret once.
PATCH/api/integrations/outbound-webhooks/{id}Toggle / rename / change events.
POST/api/integrations/outbound-webhooks/{id}/rotate-secretNew HMAC secret (returned once).
POST/api/integrations/outbound-webhooks/{id}/testSynchronous test POST — does not affect the failure counter.
POST/api/integrations/outbound-webhooks/{id}/redeliverReplay 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 for localhost during 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.