# Webhooks & events

> Event names, delivery payloads, signing and verification, retries, the deliveries API, and polling or replaying events.
>
> Source: https://www.weave.directory/docs/webhooks

Webhooks send a signed HTTP `POST` to your URL when something happens in a repository. Every event is first written to a durable, per-repository log, so nothing a webhook or client can miss: deliveries are retried, and clients can replay or poll by sequence number.

## Create a webhook

**Web UI:** repository → **Settings** → **Webhooks** → **Add Webhook**. Enter the **Payload URL** (https), a **Secret** (at least 16 characters, used to sign deliveries), tick the **Trigger Events** and **Active**.

**API** (repository admin, scope `repo:admin`):

```bash
curl -s https://weave.directory/api/v1/repos/$REPO_ID/webhooks \
  -H "Authorization: Bearer $WEAVE_PAT" -H "Content-Type: application/json" \
  -d '{"name": "ci", "url": "https://ci.example.com/weave", "secret": "a-long-random-secret-value",
       "events": ["workstream.weaved", "resolution_point.*", "message.sent"]}'
```

`events` defaults to `["workstream.weaved"]`. Secrets are encrypted at rest and never returned (`secretSet: true`). Rotate one with `PATCH` `{"secret": "…"}`. See the [API reference](https://www.weave.directory/docs/api#platform-webhooks) for every webhook route.

## Event names

| Event | Sent when | `data` |
| --- | --- | --- |
| `stitch.created` | A stitch was submitted (before the weave check) | `workstream`, `changeset`, `change_id`, `base_head`, `author`, `actor`, `agent`, `intent`, `why`, `ops` (count) |
| `workstream.weaved` | A stitch weaved (the head moved) | `entry` (history entry) |
| `workstream.promoted` | A promotion weaved | `entry` |
| `workstream.inherited` | A child inherited a parent's stitch | `entry` |
| `workstream.created` | A workstream was created | `workstream` |
| `workstream.deleted` | A workstream was removed (archived) | `workstream`, `archived: true`, `actor`, `force`, `unpromoted`, `closed_change_requests`, `closed_suggestions`, `released_claims`, `closed_resolution_points` |
| `workstream.restored` | A removed workstream was restored | `workstream`, `archived: false`, `actor` |
| `workstream.protection_changed` | Protection was set or removed | `protection`, `actor` |
| `change_request.opened` / `change_request.resolved` | A change request opened / was resolved | `change_request` |
| `suggestion.created` | A suggestion was routed to an owner | `suggestion` |
| `suggestion.resolved` / `suggestion.reopened` | Accepted, rejected, stale or withdrawn / back to pending | `suggestion` |
| `resolution_point.opened` | A rejected clash kept both versions | `resolution_point` |
| `resolution_point.resolved` | A point was resolved (or closed by archiving) | `resolution_point` |
| `message.sent` | Any message in the repository, direct ones included | `message` |
| `tag.created` / `tag.deleted` | A tag was created / deleted | `tag`, `actor` (and `why` on delete) |
| `attestation.created` | An attestation was recorded | `attestation`, `actor` |
| `purge.requested` / `purge.approved` / `purge.cancelled` | A lawful purge changed state (also sent on expiry) | `purge`, `kind`, `state`, `requestedBy`, `decidedBy`, `manifest`, `matches` (never the reason or the text) |
| `repo.deleted` | The repository was purged | `repo`, `actor` |

**Subscription patterns:** exact names, `*` (everything), `<family>.*` (`workstream.*`, `stitch.*`, `change_request.*`, `suggestion.*`, `resolution_point.*`, `message.*`, `tag.*`, `attestation.*`, `purge.*`, `repo.*`), `promotion.*` (= `workstream.promoted`) and the legacy `push` (= `workstream.weaved`). `:` is read as `.`. An empty list or `active: false` receives nothing.

A webhook subscribed to `message.sent` receives **every** message of the repository, including direct ones: treat its URL as sensitive.

## The delivery

```http
POST /weave HTTP/1.1
Content-Type: application/json
User-Agent: Weave-Webhooks/1
X-Weave-Event: workstream.weaved
X-Weave-Event-Id: 6f1c2a9e-0b5d-4a51-9a7e-3c1d2b4e5f60
X-Weave-Delivery: 0c9e…
X-Weave-Timestamp: 1793361600
X-Weave-Signature: sha256=5d41402abc4b2a76b9719d911017c592…
```

```json
{ "seq": 412, "eventId": "6f1c2a9e-0b5d-4a51-9a7e-3c1d2b4e5f60", "event": "workstream.weaved",
  "repo": "cm3k…", "createdAt": "2026-10-30T12:00:00Z",
  "data": { "entry": { "seq": 37, "workstream": "dev", "changeset": "…", "head_before": "…", "head_after": "…",
    "kind": "stitch", "author": "alice", "actor": "alice", "at": "2026-10-30T12:00:00Z" } } }
```

- The body is identical on every attempt. `seq` increases per repository; deliveries are **not ordered**, so use `seq`.
- `X-Weave-Timestamp` is unix **seconds**.
- Redirects are not followed (a `3xx` counts as a failure). Targets on private, loopback or link-local addresses are refused.

## Verify the signature

`X-Weave-Signature` is `sha256=` + hex HMAC-SHA256(secret, `"<X-Weave-Timestamp>.<raw body>"`). Verify it on the raw bytes, in constant time, and reject stale timestamps:

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

// rawBody: the request body exactly as received, before JSON parsing.
export function verifyWeaveWebhook(rawBody: Buffer, headers: Record<string, string>, secret: string): boolean {
  const timestamp = headers["x-weave-timestamp"];
  const signature = headers["x-weave-signature"] ?? "";
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5-minute window
  const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  const a = Buffer.from(signature), b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Then **skip the event if you already processed its `X-Weave-Event-Id`**, and answer any `2xx` quickly (do the work afterwards). The request times out after 10 seconds.

## Retries

Any `2xx` counts as delivered. Anything else — a status, a timeout, a connection error — is retried with exponential backoff (30 s, 1 min, 2 min, … capped at 1 hour, with jitter) until the delivery is 24 hours old, then it is `failed`. Deliveries are **at least once**: a delivery can arrive twice, so always dedupe on `X-Weave-Event-Id`. A redelivery has a new `X-Weave-Delivery` and the same event id. Finished deliveries and events are kept 30 days.

## Deliveries and redelivery

Repository admins, on the Weave server:

```bash
curl -s "https://api.weave.directory/api/weave/$REPO_ID/webhooks/deliveries?status=failed&limit=50" \
  -H "Authorization: Bearer $WEAVE_PAT"
curl -s -X POST https://api.weave.directory/api/weave/$REPO_ID/webhooks/deliveries/$DELIVERY_ID/redeliver \
  -H "Authorization: Bearer $WEAVE_PAT"
```

A delivery is `{id, webhookId, url, eventId, event, seq, status: pending|delivered|failed, attempts, nextAttemptAt, lastStatusCode?, lastError?, redeliveryOf?, createdAt, updatedAt, deliveredAt?}`. Page with `before=<next>`. Redeliver answers `{delivery}`: a new delivery of the same event, due now, with its own 24 hours of retries.

## Polling and replay

Without a public URL, poll the durable event log (read access):

```bash
curl -s "https://api.weave.directory/api/weave/$REPO_ID/events?since=412&limit=100" \
  -H "Authorization: Bearer $WEAVE_TOKEN"
```

```json
{ "events": [{ "seq": 413, "eventId": "…", "event": "suggestion.created", "repo": "cm3k…", "createdAt": "…", "data": { … } }],
  "next": 413, "more": false, "latest": 413, "truncated": false }
```

Continue with `since=next` while `more`. `truncated: true` means events after `since` were already pruned: re-read state. Events with recipients (suggestions, resolution points, messages, notices) are only shown to their recipients; `seq` gaps are normal.

Over the WebSocket (`wss://api.weave.directory/ws?repo=<repoId>`), send `{"Weave": {"id": 1, "request": {"type": "subscribe", "since": 412}}}` after the handshake to replay everything after `412` and then receive live events, with no gap and no duplicate.

Extra events that only reach the WebSocket and polling, never webhooks: `dependency.notice`.
