<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->

# Outbound webhooks (the firehose)

> Implemented in #240. (The older `webhooks.md` is the original planning note.)

Izri POSTs a signed JSON payload to any HTTPS endpoint you register whenever an
event happens on one of your organization's projects. This is the self-serve
"escape hatch" — any customer can build their own integration in an afternoon,
and our first-party integrations (Slack, Linear, …) consume the same firehose.

Manage endpoints under **Organization → Settings → Webhooks**, or via the
`outboundWebhooks` tRPC router (`create` / `list` / `update` / `rotateSecret` /
`delete` / `test` / `listDeliveries`).

## Events

| Event | Fires when |
| --- | --- |
| `delta_report.updated` | A delta report is (re)computed for a change set — the headline "something happened on this PR" signal. |
| `scope_check.completed` | A scope check finishes. *(reserved — accepted as a subscription filter, not yet emitted)* |
| `test_run.completed` | A test run reaches a terminal state. *(reserved — accepted as a subscription filter, not yet emitted)* |

An empty `eventTypes` list receives **all** events; otherwise only the listed
ones. (`finding.created` is intentionally not offered — findings are embedded in
check rows, not first-class entities.)

## Request format

```
POST <your-url>
Content-Type: application/json
User-Agent: izri-webhooks/1
X-Izri-Event: delta_report.updated
X-Izri-Delivery: <ULID>            # unique per delivery; use it to dedupe
X-Izri-Signature-256: sha256=<hex>
```

The body is event-specific JSON. For `delta_report.updated`:

```jsonc
{
  "changeSetId": "01H…",
  "deltaReportId": "01H…",
  "umbrellaState": "passing",      // passing | failing | unknown
  "status": "complete",            // pending | partial | complete | error
  "alignmentScore": 87,            // 0-100 | null
  "testPassRate": 1.0,             // 0-1 | null
  "hallucinationScore": 92,        // 0-100 | null
  "visualDiffScore": 100           // 0-100 | null
}
```

## Verifying the signature

`X-Izri-Signature-256` is `sha256=` + the hex HMAC-SHA256 of the **raw request
body** using your subscription's signing secret (shown once at creation; rotate
it any time). This mirrors GitHub's scheme.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(rawBody: string, header: string, secret: string): boolean {
  const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`
  const a = Buffer.from(expected)
  const b = Buffer.from(header)
  return a.length === b.length && timingSafeEqual(a, b)
}
```

Always compute the HMAC over the exact bytes you received, before any JSON
parsing.

## Delivery, retries, and auto-disable

- **At-least-once.** A delivery may arrive more than once. Dedupe on
  `X-Izri-Delivery`.
- **Retries.** A non-2xx response or network error is retried with exponential
  backoff (2s, 4s, 8s, …) up to 5 attempts, then the delivery is marked
  `failed`.
- **Auto-disable.** After 10 consecutive *failed* deliveries the subscription is
  disabled so a dead endpoint stops costing retries. Any success resets the
  counter; re-enabling a disabled subscription clears the streak.
- **Delivery log.** The last 50 attempts per subscription are retained
  (`webhook_deliveries`) and surfaced in the dashboard for debugging.

Respond `2xx` quickly (under a few seconds); do heavy work asynchronously.
