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

# Webhooks

> Push page and metadata activity to external systems

Outbound webhooks push page activity to external systems — CI, data warehouses, Slack bridges, custom apps — instead of making them poll.

## Events

| Event                   | Fires when                                                       |
| ----------------------- | ---------------------------------------------------------------- |
| `page.created`          | A page's first revision is written (any door: HTTP, MCP, import) |
| `page.updated`          | A new revision of an existing page is written                    |
| `page.deleted`          | A page is deleted (admin or personal deletion)                   |
| `page.metadata.updated` | A metadata patch lands (payload lists `changedKeys`)             |

Personal-space activity never emits webhooks. A subscription can cover all shared spaces or be filtered to one space.

## Delivery contract

Deliveries are `POST`s with a JSON body:

```json theme={null}
{
  "id": "5f2c9d5e-…",
  "event": "page.metadata.updated",
  "occurredAt": "2026-07-19T08:12:45.120Z",
  "actor": { "id": "agent-key-id", "kind": "agent" },
  "page": { "path": "deals/acme", "space": "deals", "title": "Acme Corp", "version": 4 },
  "changedKeys": ["stage", "sources"]
}
```

Headers: `X-Coco-Event`, `X-Coco-Delivery`, `X-Coco-Webhook`, and `X-Coco-Signature` — an HMAC-SHA256 of the raw body with the webhook's signing secret, formatted `sha256=<hex>`.

### Verifying a signature

```js theme={null}
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
```

Non-2xx responses and network failures retry with backoff (3 retries by default). Every attempt's outcome lands in the delivery log (`GET /admin/webhooks/:id/deliveries`, or the **Deliveries** panel in the admin UI).

## Management (org admin)

* `GET/POST /admin/webhooks` — list / create (`{url, events, spaceSlug?, description?}`; the signing secret is returned **once** on create)
* `PATCH /admin/webhooks/:id` — update URL, events, space filter, enable/disable
* `POST /admin/webhooks/:id/rotate-secret` — new secret, returned once
* `POST /admin/webhooks/:id/test` — send a `webhook.test` delivery and report the outcome
* `DELETE /admin/webhooks/:id`

## Security posture

<Warning>
  Webhook signing secrets are always stored encrypted. Webhooks require `COCO_SETTINGS_ENCRYPTION_KEY` on the API runtime (create/rotate return a clear 503 without it).
</Warning>

* Endpoints must be **https**; plain http requires the deployment to opt in with `COCO_WEBHOOKS_ALLOW_HTTP=1` (payloads then transit unencrypted).
* **SSRF guard**: private, loopback, link-local, CGNAT, and reserved addresses (IPv4 and IPv6, including v4-mapped forms) are rejected — literals at create time, and again at **delivery time after DNS resolution**, so a hostname pointing at an internal address is blocked too (recorded in the delivery log, no retries). A small time-of-check/time-of-use window inherent to DNS remains; deployments with strict requirements should add egress filtering. `COCO_WEBHOOKS_ALLOW_PRIVATE_URLS=1` disables the guard for intentionally internal deployments.
