> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kansato.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Outbound webhooks

> Receive Whistle events at your HTTPS endpoint and verify signed deliveries.

## Overview

Whistle **pushes** events to URLs you configure in **Settings → Developer → Webhooks** (outbound from Whistle, inbound to your stack). Each `POST` is signed so you can reject forged traffic.

Endpoint URLs and secrets are managed in the dashboard, not through the SDK.

## Event types

| Event                        | When it fires                                                  |
| ---------------------------- | -------------------------------------------------------------- |
| `case.created`               | A new case was ingested.                                       |
| `case.resolved`              | A case was resolved with a policy action.                      |
| `case.dismissed`             | A case was dismissed (false positive, duplicate, etc.).        |
| `enforcement.action.created` | An enforcement action was recorded against a subject.          |
| `enforcement.action.updated` | An existing enforcement action was updated (expiry, metadata). |
| `enforcement.action.revoked` | An enforcement action was revoked or lifted.                   |
| `appeal.created`             | An appeal was submitted against an enforcement action.         |
| `appeal.reviewed`            | An appeal was reviewed and a decision was made.                |

## Request envelope

Each delivery body is a JSON object:

```json theme={null}
{
  "id": "evt_abc123",
  "type": "report.created",
  "createdAt": "2026-04-04T12:00:00.000Z",
  "data": { }
}
```

* `id` -- unique event ID. Use for idempotency and deduplication.
* `type` -- the event name from your subscription.
* `createdAt` -- ISO 8601 timestamp of when the event occurred.
* `data` -- event-specific payload.

## HTTP headers

Whistle sends `POST` with `Content-Type: application/json; charset=utf-8` and:

| Header                  | Meaning                                               |
| ----------------------- | ----------------------------------------------------- |
| `x-whistle-event`       | Event type string.                                    |
| `x-whistle-event-id`    | Same as the envelope `id`.                            |
| `x-whistle-delivery-id` | Internal delivery row ID (retries reuse the same ID). |
| `x-whistle-signature`   | HMAC-SHA256 signature for verification.               |

## Verifying signatures with the SDK

The SDK provides `verifyWebhookSignature` to verify incoming webhooks. It parses the signature header, checks the timestamp, computes the HMAC, and returns the typed event.

### Next.js App Router

```typescript theme={null}
// app/api/webhooks/whistle/route.ts
import { verifyWebhookSignature } from "@kansato/whistle-sdk";

export async function POST(request: Request) {
  const signature = request.headers.get("x-whistle-signature") ?? undefined;
  const payload = await request.text();

  try {
    const event = verifyWebhookSignature({
      payload,
      signature,
      secret: process.env.WHISTLE_WEBHOOK_SECRET!,
      tolerance: 300, // seconds (default: 300)
    });

    switch (event.type) {
      case "report.created":
        // event.data contains the report payload
        console.log("New report:", event.data);
        break;
      case "enforcement.action.created":
        console.log("Enforcement action:", event.data);
        break;
      case "appeal.created":
        console.log("New appeal:", event.data);
        break;
    }

    return Response.json({ received: true });
  } catch (err) {
    console.error("Webhook verification failed:", err);
    return Response.json({ error: "Invalid signature" }, { status: 400 });
  }
}
```

### Express

```typescript theme={null}
import express from "express";
import { verifyWebhookSignature } from "@kansato/whistle-sdk";

const app = express();

app.post("/webhooks/whistle", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-whistle-signature"] as string | undefined;

  try {
    const event = verifyWebhookSignature({
      payload: req.body,
      signature,
      secret: process.env.WHISTLE_WEBHOOK_SECRET!,
    });

    // Handle event...
    res.json({ received: true });
  } catch {
    res.status(400).json({ error: "Invalid signature" });
  }
});
```

### Manual verification

The `x-whistle-signature` header has the format:

```text theme={null}
t=<unixSeconds>,v1=<hex>
```

1. Parse the timestamp `t` and signature `v1` from the header.
2. Reject if the timestamp is older than your tolerance (recommended: 300 seconds).
3. Compute HMAC-SHA256 of the string `<t>.<rawBody>` using your signing secret.
4. Compare the resulting hex digest to `v1` using constant-time comparison.

The raw body must be the exact bytes Whistle sent. Verify before JSON parsing.

## Retries and logs

Failed deliveries retry with backoff. Treat `x-whistle-delivery-id` (or the envelope `id`) as your idempotency key so a retried `POST` does not double-apply side effects.

The **View deliveries** panel in the dashboard shows status, HTTP status codes, errors, and payload snapshots for recent attempts.
