# Event Webhooks

Developer-preview NVR plugin sidecar. It polls `nvrd` motion/smart events and
forwards each event edge to a user-owned webhook.

This is the first real plugin used to exercise the marketplace, permissions, and
sidecar packaging path. When installed through NVR, the daemon supervisor starts
it out of process, injects a scoped token/config environment, captures logs, and
restarts it if it exits. A sidecar crash does not affect recording.

## Permissions

- `events:read` - reads event edges from `nvrd`.
- `network:outbound` - posts to the configured webhook URL.
- `ui:settings.panel` - settings panel for webhook URL, secret, camera, and
  event filters.

## Run Locally

```bash
go build -o event-webhooks ./plugins/event-webhooks/cmd/event-webhooks

NVR_BASE_URL=http://127.0.0.1:8085 \
NVR_API_TOKEN=dev-token \
NVR_PLUGIN_STATE_DIR=/tmp/nvr-event-webhooks \
CAMERA_ID=cam01 \
EVENT_TYPE=motion \
WEBHOOK_URL=https://example.com/nvr/events \
WEBHOOK_SECRET=shared-secret \
./event-webhooks
```

For normal use, install the signed marketplace package from the admin Plugins
page instead of running it manually.

Installed extensions start with no camera access. Grant the selected camera on
the Extensions page before starting delivery. Use **All cameras** only when the
webhook is intended to receive current and future camera events.

The signed manifest declares the settings panel. Camera is a named picker,
Webhook URL is URL-validated, and Shared secret is write-only in the admin API:
the page shows whether one is saved but never reads the plaintext back.

Webhook payload:

```json
{
  "plugin_id": "com.glidevizion.event-webhooks",
  "camera_id": "cam01",
  "delivery_id": "cam01:4611686018427387904",
  "event": {
    "id": "4611686018427387904",
    "time_ms": 1779742942165,
    "channel": 1,
    "event_type": "motion",
    "active": true
  }
}
```

Event IDs remain JSON strings so all 62-bit daemon IDs are exact in JavaScript
and downstream automation systems. `delivery_id` is stable across retries and
is also sent as `X-NVR-Delivery-ID`; receivers should store it as their
idempotency key.

When `WEBHOOK_SECRET` is set, requests include
`X-NVR-Signature: sha256=<hex hmac>` over the JSON body.

Delivery is at least once. The sidecar atomically checkpoints its inclusive
timestamp plus the event IDs already delivered at that timestamp under
`NVR_PLUGIN_STATE_DIR/cursor.json`. This prevents restarts from replaying a
lookback window, prevents same-millisecond events from being skipped, and
preserves progress when a later webhook in the same poll fails. A crash between
the webhook accepting a request and the cursor checkpoint can still retry that
request, which is why receivers must deduplicate by `delivery_id`.

Backlog delivery is bounded even after a long outage. Each daemon request asks
for at most 100 events after the cursor's exclusive `after_id`, and one poll
drains at most 10 pages before yielding to the normal loop. The last delivered
`(time_ms,id)` becomes the next page marker, so a burst of events sharing one
millisecond cannot starve newer rows or force the sidecar to load the full
retention window into memory.

The sidecar reports readiness and delivery failures through the plugin
heartbeat API. The Plugins page shows its last heartbeat and whether delivery
is active or needs attention.
