Feature Change Triggers for Geospatial Webhooks
Feature change triggers convert a spatial database write — a parcel edit, a route update, a sensor footprint move — into a reliable, signed webhook event delivered to downstream consumers. This topic is part of Core Event Fundamentals & Architecture, which covers the full event pipeline from spatial mutation to consumer acknowledgement.
Prerequisites
Before implementing, confirm your stack meets these baselines:
Architecture blueprint
A spatial feature change moves through four layers before a consumer acknowledges receipt:
Step-by-step implementation
1. Intercept spatial mutations at the data layer
Spatial databases rarely emit events in a webhook-ready format. Use PostgreSQL logical replication with the pgoutput plugin, or a Debezium CDC connector, to stream committed INSERT, UPDATE, and DELETE operations from your feature tables without polling.
Logical decoding guarantees events are emitted only after the originating transaction commits — no phantom updates from rolled-back writes. Extract four fields at minimum: primary key, old geometry (for UPDATE/DELETE), new geometry, and the operation type.
import asyncpg
REPLICATION_DSN = "postgresql://user:pass@localhost/gis?replication=database"
async def stream_feature_changes(slot_name: str, publication: str):
conn = await asyncpg.connect(dsn=REPLICATION_DSN)
await conn.execute(
"SELECT pg_create_logical_replication_slot($1, 'pgoutput')", slot_name
)
async with conn.replication(slot_name, output_plugin="pgoutput",
options={"publication_names": publication}) as stream:
async for message in stream:
yield parse_wal_message(message) # returns dict with op, pk, old_geom, new_geom
For a complete walkthrough of how this feeds into a full webhook architecture, see How to design a geospatial webhook architecture in Python.
2. Normalize and validate geometry
Raw database geometries contain topology errors, mismatched coordinate reference systems, and floating-point noise that break downstream consumers. Enforce spatial integrity before packaging any event, using CRS Normalization Strategies as the authoritative reference for reprojection patterns and the staged checks in Geometry Validation Pipelines for repairing malformed input before it reaches the broker.
The three mandatory checks are:
- CRS alignment — reproject all geometries to
EPSG:4326for web delivery (orEPSG:3857for tile-based systems). Always include the EPSG code in the event envelope so consumers do not have to guess. - Topology validation — verify polygon closure, absence of self-intersections, and minimum vertex counts.
shapely’smake_valid()repairs most topology errors automatically; raise on the remainder. - Coordinate precision reduction — snap to 6–7 decimal places to eliminate floating-point drift across consumers and reduce payload size.
from shapely.geometry import shape, mapping
from shapely.validation import explain_validity, make_valid
from shapely.ops import transform as shapely_transform
from shapely import set_precision
import pyproj
def normalize_geometry(geojson_geometry: dict, source_epsg: int = 4326) -> dict:
"""Validate, repair, reproject, and reduce precision of a GeoJSON geometry."""
geom = shape(geojson_geometry)
if not geom.is_valid:
geom = make_valid(geom)
if not geom.is_valid:
raise ValueError(f"Irreparable topology: {explain_validity(geom)}")
if source_epsg != 4326:
transformer = pyproj.Transformer.from_crs(
f"EPSG:{source_epsg}", "EPSG:4326", always_xy=True
)
# Use shapely.ops.transform, not the deprecated pyproj.transform
geom = shapely_transform(transformer.transform, geom)
# Snap coordinates to 7 decimal places (~1 cm precision at equator)
geom = set_precision(geom, grid_size=1e-7)
return mapping(geom)
Validate the resulting GeoJSON against the RFC 7946 schema with Pydantic before enveloping:
from pydantic import BaseModel, field_validator
from typing import Literal
class GeoJSONGeometry(BaseModel):
type: Literal["Point", "LineString", "Polygon", "MultiPolygon",
"MultiPoint", "MultiLineString", "GeometryCollection"]
coordinates: list
@field_validator("coordinates")
@classmethod
def coordinates_not_empty(cls, v: list) -> list:
if not v:
raise ValueError("coordinates must not be empty")
return v
class SpatialFeatureEvent(BaseModel):
feature_id: str
operation: Literal["INSERT", "UPDATE", "DELETE"]
geometry: GeoJSONGeometry
crs: str = "EPSG:4326"
properties: dict = {}
3. Construct the event envelope
Wrap validated geometry in a CloudEvents 1.0 envelope to ensure interoperability across heterogeneous consumers. The envelope separates routing metadata from spatial payload, making it possible to filter and forward events without deserializing geometries.
Add three spatial extension attributes: crs (always include the EPSG code), bbox, and spatialoperation. Generate a deterministic idempotency key from the feature ID and operation, following the approach in Event Key Generation for Spatial Data.
import uuid
import hashlib
import json
from datetime import datetime, timezone
def build_event_envelope(
feature_event: SpatialFeatureEvent,
source: str = "/postgis/features",
) -> dict:
# Deterministic ID: same feature + operation + geometry hash → same UUID
geom_hash = hashlib.sha256(
json.dumps(feature_event.geometry.model_dump(), sort_keys=True).encode()
).hexdigest()[:16]
event_id = str(uuid.uuid5(
uuid.NAMESPACE_URL,
f"{feature_event.feature_id}:{feature_event.operation}:{geom_hash}"
))
geom_dict = feature_event.geometry.model_dump()
return {
"specversion": "1.0",
"type": f"com.geospatialwebhook.feature.{feature_event.operation.lower()}",
"source": source,
"id": event_id,
"time": datetime.now(timezone.utc).isoformat(),
"datacontenttype": "application/geo+json",
# Spatial CloudEvents extensions
"crs": feature_event.crs,
"spatialoperation": feature_event.operation,
"data": {
"type": "Feature",
"geometry": geom_dict,
"properties": {
"feature_id": feature_event.feature_id,
**feature_event.properties,
},
},
}
When evolving schemas across teams, use additive-only minor changes: new optional fields are safe; renaming or removing fields requires a version bump and coordinated consumer migration. The best practices for spatial event payload schemas page covers versioning strategies in depth.
4. Route and partition spatial events
Publish the enveloped event to your message broker. Partitioning strategy determines consumer throughput and ordering guarantees. For geospatial workloads:
- Spatial tile key (H3 or Quadkey): All mutations affecting the same map cell land in the same partition, preserving geographic ordering and preventing tile cache stampedes during bulk boundary edits. Coordinate this with Tile Update Event Pipelines to avoid thundering-herd cache invalidation.
- Tenant ID: Guarantees multi-tenant isolation and simplifies compliance auditing without cross-contaminating event streams.
- Feature class: Routes parcel, road, and sensor events to specialized consumer groups with distinct processing logic.
If your architecture also handles real-time telemetry from moving assets, see Sensor Data Routing Patterns before combining static feature updates with high-frequency IoT streams — partition skew becomes a real concern when event rates differ by orders of magnitude.
import aiokafka
import json
async def publish_to_broker(
envelope: dict,
topic: str = "spatial-feature-events",
partition_key: str | None = None,
) -> None:
producer = aiokafka.AIOKafkaProducer(
bootstrap_servers="localhost:9092",
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
key_serializer=lambda k: k.encode("utf-8") if k else None,
compression_type="gzip", # spatial payloads compress well
)
await producer.start()
try:
key = partition_key or envelope.get("data", {}).get("properties", {}).get("feature_id")
await producer.send_and_wait(topic, value=envelope, key=key)
finally:
await producer.stop()
5. Dispatch webhooks with retry and delivery guarantees
The dispatcher consumes from the broker and sends HTTPS POST requests to subscriber endpoints. The two non-negotiable properties are HMAC-SHA256 signature on every request and exponential backoff with jitter on transient failures.
At-least-once delivery is the practical default for spatial webhooks: consumers must be idempotent (see Cache-backed Idempotency Checks for a Redis-based implementation). Exactly-once delivery requires distributed transaction coordination that is rarely worth the complexity for spatial payloads.
import asyncio
import hashlib
import hmac
import json
import httpx
import random
def sign_payload(payload: dict, secret: str) -> str:
body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
async def dispatch_webhook(
url: str,
envelope: dict,
secret: str,
max_retries: int = 5,
base_delay: float = 1.0,
) -> int:
headers = {
"Content-Type": "application/json",
"X-Webhook-Signature": sign_payload(envelope, secret),
"X-Event-Id": envelope["id"],
}
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
for attempt in range(max_retries):
try:
resp = await client.post(url, json=envelope, headers=headers)
resp.raise_for_status()
return resp.status_code
except httpx.HTTPStatusError as exc:
if 400 <= exc.response.status_code < 500:
raise # 4xx: client error, do not retry
# 5xx: jittered exponential backoff
delay = min(base_delay * (2 ** attempt), 60.0) + random.uniform(0, 1)
await asyncio.sleep(delay)
except (httpx.TimeoutException, httpx.ConnectError):
delay = min(base_delay * (2 ** attempt), 60.0) + random.uniform(0, 1)
await asyncio.sleep(delay)
# Exhausted retries — caller should route to DLQ
raise RuntimeError(f"Webhook delivery failed after {max_retries} attempts: {url}")
For HMAC signature verification and endpoint hardening on the receiving side, see Securing Webhook Endpoints with Spatial Token Validation.
Spatial validation and error handling
Beyond topology checks, handle these spatial-specific failure modes before they reach the broker:
- Empty or null geometry on
UPDATE: Treat as aDELETE— the feature has been blanked. Log the anomaly and emit a deletion event with the last known geometry as context. - Coordinate ring orientation: GeoJSON exterior rings must be counter-clockwise (RFC 7946 §3.1.6). Shapely’s
orient()corrects this silently; PostGIS may not. - Geometry type change: A polygon becoming a multipolygon on
UPDATEis a legal mutation but can break consumers that cast the geometry type. Include both the old and new geometry types in the event envelope’sspatialoperationcontext. - Oversized payloads: Complex boundaries often exceed broker message limits. Simplify with
shapely.simplify(tolerance=0.00001, preserve_topology=True)and include asimplified: trueflag in the envelope properties so consumers can request the full geometry from the source API if needed.
Retry, backoff and delivery guarantees
| Scenario | Recommended behaviour |
|---|---|
Consumer returns 2xx |
Acknowledge and commit broker offset |
Consumer returns 429 with Retry-After |
Honour the header; do not count as retry attempt |
Consumer returns 4xx (not 429) |
Dead-letter immediately; alert operator |
Consumer returns 5xx |
Jittered exponential backoff; DLQ after max retries |
| Network timeout | Same as 5xx |
| Broker unavailable at publish time | WAL buffer retains CDC events; resume from replication slot |
At-least-once semantics require idempotent consumers. The id field in the CloudEvents envelope serves as the idempotency key. For Redis-based deduplication of inbound webhook signatures, see Using Redis to Cache Spatial Webhook Signatures.
Verification
Confirm the pipeline works end-to-end with an integration test that inserts a feature into a test PostGIS table and asserts a signed POST arrives at a local HTTP test server:
import asyncio
import pytest
from aiohttp import web
received: list[dict] = []
async def mock_consumer(request: web.Request) -> web.Response:
received.append(await request.json())
return web.Response(status=200)
@pytest.mark.asyncio
async def test_feature_change_triggers_webhook():
app = web.Application()
app.router.add_post("/hook", mock_consumer)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "localhost", 9999)
await site.start()
# Insert a test feature into PostGIS (requires a test DB)
await insert_test_parcel(pg_dsn="postgresql://user:pass@localhost/test_gis")
# Allow pipeline to propagate
await asyncio.sleep(2)
assert len(received) == 1
env = received[0]
assert env["type"].startswith("com.geospatialwebhook.feature.")
assert env["specversion"] == "1.0"
assert env["crs"] == "EPSG:4326"
# Verify HMAC — re-sign with known secret and compare
expected = sign_payload(env, secret="test-secret")
assert env.get("_headers", {}).get("X-Webhook-Signature", "") == expected
await runner.cleanup()
Troubleshooting
| Symptom | Likely spatial cause | Fix |
|---|---|---|
| Consumer receives duplicate events | Replication slot not acknowledged after dispatcher crash | Store ACK state in Redis before committing offset; use pg_replication_slot_advance() only after successful DLQ or delivery |
| Geometry validation failures spike after bulk import | Source data mixed CRS or self-intersecting polygons from shapefile conversion | Run ST_IsValid + ST_MakeValid at import time in PostGIS, not just at event time |
| Payload size exceeds broker limit on polygon updates | Administrative boundary revisions include full coastline precision | Apply Douglas-Peucker simplification before enveloping; store original in object storage and include a geometry_url field |
| Consumer lag grows during dense urban tile updates | Partition skew — too many events hitting the same H3 cell | Drop resolution by one level (e.g., H3 resolution 9 → 8) to spread load; accept slightly coarser geographic ordering |
| Webhook signature mismatch at consumer | JSON key order differs between dispatcher and consumer | Always serialize with sort_keys=True, separators=(",", ":") on both sides; use the raw POST body for HMAC, not re-serialized JSON |
| Events out of order for a single feature | Multiple broker partitions receiving updates for the same feature ID | Partition by feature_id hash when ordering per feature matters more than geographic locality |
FAQ
Should I use logical replication or a polling loop to detect feature changes?
Prefer PostgreSQL logical replication (via pgoutput or wal2json) or Debezium CDC over polling. Polling introduces latency proportional to the interval, adds query load at scale, and misses rapid bursts of changes between ticks. Logical decoding emits events immediately after each committed transaction with sub-second latency.
What is the right partitioning key for a geospatial event stream?
Partition by spatial tile key (H3 or Quadkey cell at an appropriate resolution) when you need geographic locality. Use tenant ID for multi-tenant isolation. Use feature class when consumers are specialized. Avoid partitioning by raw geometry hash — distribution is uneven in dense urban areas.
How large can a spatial webhook payload be before I need to chunk it?
Most webhook endpoints and brokers cap messages at 256 KB–1 MB. Complex polygon boundaries or dense multilinestrings can exceed this. Simplify with shapely’s .simplify() before enveloping, or split multi-part geometries into individual events with a shared correlation ID.
How do I handle a CRS mismatch between the source database and webhook consumers?
Establish a canonical CRS at the event boundary — EPSG:4326 for web delivery is the safest default. Convert every geometry to that CRS inside the validation step using pyproj, and stamp the crs extension field in the CloudEvents envelope. Consumers that internally use EPSG:3857 or a national grid perform their own local reprojection from the canonical form.
Related
- Core Event Fundamentals & Architecture — parent domain covering the full event-driven spatial pipeline
- How to Design a Geospatial Webhook Architecture in Python — end-to-end architecture walkthrough for this trigger pattern
- Sensor Data Routing Patterns — handling high-frequency IoT streams alongside discrete feature change events
- Event Key Generation for Spatial Data — generating deterministic idempotency keys from feature geometry hashes
- CRS Normalization Strategies — canonical reprojection patterns for mixed-CRS spatial event streams