# Events, watches and signed webhooks

`/v1/events` is a separately metered history read. A watch selects future changes for delivery to your webhook. No notification does not establish no change: entitlement, source evidence or delivery may be unavailable.

Use GET `/v1/events` with `q`, `id` or `point` for current history reads. POST `/v1/events/query` has a schema but its body selectors are not yet consumed: a valid `parcel_ids` sample returns 400. V2 `area` and `parcel_ids` on GET are not applied either; do not rely on them as filters.

## Create a watch and receiver

[POST /v1/watches](../../operations/watch_create.md) accepts items selecting a `parcel_id`, `gmina_teryt` or polygon with `types` filters. One item consumes one slot; a municipality item is not expanded into billable parcels. Optional `Idempotency-Key` replays the same account/key/items for 24 hours. Changed items return 409; a deleted watch is not recreated.

Supported filters: `pog.changed`, `wz.changed`, `mpzp.changed`, `flood.changed`, `landslide.changed`, `protected.changed`, `buildings.changed`.


EventV2 types such as `gunb_permit` and `plan_general_adopted` have schemas but no production notification producer in this revision. Registration returns `event_type_has_no_producer`. Detection time does not by itself establish the legal effective date of a change.

Configure the URL with [PUT /v1/watches/{watch_id}/webhook](../../operations/watch_webhook_configure.md). Save `result.secret` securely: configuration and rotation reveal it once; reads never do. URLs require public HTTPS port 443, no credentials/fragment and exclusively public DNS answers. Redirects are not followed. Never include owner or KW data in the URL.

Configuration starts from the current cursor and cancels the old backlog. [Secret rotation](../../operations/watch_secret_rotate.md) preserves queued work but changes its signing key immediately. Coordinate the receiver's key change to accept new signatures. Removing the webhook cancels pending deliveries.

## Verify raw bytes before JSON

`X-Landfeed-Signature` is `t=<unix>,v1=<hex>`. HMAC-SHA256 signs ASCII timestamp + dot + exact raw body bytes using the returned secret's bytes. Do not reserialize JSON before verification. Reject timestamps older than 300 seconds or over 300 seconds in the future; synchronize receiver clocks.

### Python

<!-- example:verify-webhook.py -->
```python
import hashlib
import hmac
import json
import re


def verify_webhook(secret, signature, raw_body, *, now):
    """Verify before parsing JSON. now is the receiver's Unix time in seconds."""
    if not isinstance(signature, str):
        return False
    match = re.fullmatch(r"t=([0-9]{1,12}),v1=([0-9a-f]{64})", signature)
    if not match or abs(now - int(match[1])) > 300:
        return False
    expected = hmac.new(secret.encode("ascii"), match[1].encode("ascii") + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, match[2])


def receive(secret, signature, raw_body, delivery_id, *, now, persist_once):
    if not verify_webhook(secret, signature, raw_body, now=now):
        raise ValueError("Invalid or stale webhook signature")
    event = json.loads(raw_body)
    if event["id"] != delivery_id:
        raise ValueError("Delivery id mismatch")
    # Atomically insert a unique delivery id and enqueue processing in one transaction.
    # persist_once returns False for an already accepted id. Return 2xx in either case.
    return persist_once(delivery_id, event)
```


`persist_once` must atomically insert a unique `X-Landfeed-Delivery` and a processing job in one transaction. A correctly signed duplicate should also receive 2xx. Do not acknowledge acceptance until durable persistence succeeds.

### TypeScript

This function verifies the signature. Run it before JSON parsing, check body id against `X-Landfeed-Delivery`, then use the same durable deduplicated persistence.

<!-- example:verify-webhook.ts -->
```typescript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(secret: string, signature: string, rawBody: Buffer, now: number): boolean {
  const match = /^t=([0-9]{1,12}),v1=([0-9a-f]{64})$/.exec(signature);
  if (!match || Math.abs(now - Number(match[1])) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(match[1], "ascii").update(".").update(rawBody).digest();
  const received = Buffer.from(match[2], "hex");
  return received.length === expected.length && timingSafeEqual(received, expected);
}
```


## Retries and entitlements

Retries reuse delivery id/body with a fresh timestamp. Backoff starts at 30 seconds, doubles up to one hour and stops after 24 hours. Ten consecutive failures disable the watch; an unsafe destination disables it immediately. [Delivery attempts](../../operations/watch_attempts.md) expose outcomes without retaining receiver response bodies.

Item quotas come from the plan and can remain zero pending operator configuration. Expired entitlements or a shared quota downgrade can pause delivery; renewal does not generate new deliveries for paused-period events. Management and current configured delivery are free; `/v1/events` reads charge separately. Customer notification webhooks and the [Stripe webhook](../../operations/billing_stripe_webhook.md) have different interfaces and secrets.
