landfeedAPI · MCP · 2.0.0PolskiAgent Markdown

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 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. 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 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

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.

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 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 have different interfaces and secrets.