Design a Geospatial Webhook Architecture in Python
To design a geospatial webhook architecture in Python, decouple spatial event ingestion from HTTP delivery using an async message broker, validate geometry with Shapely before dispatch, sign every payload with HMAC-SHA256, and apply exponential backoff with dead-letter routing for undeliverable events.
This page is part of the Feature Change Triggers topic under Core Event Fundamentals & Architecture. It focuses on the single concrete question of how to wire these four concerns together into a runnable Python service.
When to use this pattern
Use the four-layer async architecture described here when:
- Your spatial data source emits high-frequency feature mutations (PostGIS triggers, GDAL pipelines, IoT sensor feeds) and a synchronous HTTP call on every write would introduce unacceptable latency or backpressure.
- Consumers need guaranteed at-least-once delivery — a fire-and-forget HTTP call on a database trigger is not sufficient, because you lose the event if the consumer is temporarily unavailable.
- Payload validation is non-trivial: you need to check geometry topology, enforce coordinate ring orientation, or reject coordinates outside WGS 84 (EPSG:4326) bounds before events leave your system.
If you only need to push a single event type to a single internal service with no retry requirements, a simpler synchronous requests.post is fine. This architecture pays off when you operate across multiple tenants, multiple endpoint registrations, or geographies with variable network reliability.
Architecture overview
The pipeline operates across four logical layers. Each isolates a single concern so that failures do not cascade and each layer can scale independently.
Layer 1 — Ingestion: Receives raw feature mutations and normalizes them into a canonical schema: event_id (UUID), feature_id, geometry (GeoJSON dict), change_type (create | update | delete), and properties. This layer is write-optimized and never blocks downstream workers.
Layer 2 — Spatial Validation: Parses geometry through Shapely, checks topology validity, and evaluates spatial delta thresholds — the same logic that drives Feature Change Triggers. Events that do not cross a meaningful delta (micro-edits, null-geometry noise) are dropped here, not forwarded.
Layer 3 — Message Broker: Publishes validated events to Redis Streams, RabbitMQ, or Kafka. Subscriber matching happens via spatial bounding-box indexes or attribute tags. The broker guarantees ordering per feature_id and persists events until acknowledged. Storing idempotency keys in the broker maps directly to the Event Key Generation for Spatial Data strategy.
Layer 4 — Async Dispatcher: Pulls events, signs payloads with HMAC-SHA256 (as detailed in Securing Webhook Endpoints with Spatial Token Validation), and delivers them over non-blocking HTTP. Delivery state, retry counts, and consumer health are tracked in a sidecar store.
Complete runnable implementation
The module below is self-contained and production-aligned. It wires all four layers: Pydantic v2 schema with Shapely validation, HMAC signing, async delivery with jitter, and dead-letter routing. No placeholder TODOs — every part runs as written.
# geospatial_webhook_dispatcher.py
#
# Dependencies:
# pip install fastapi aiohttp pydantic shapely uvicorn
#
# Run with:
# uvicorn geospatial_webhook_dispatcher:app --host 0.0.0.0 --port 8000
import asyncio
import hashlib
import hmac
import json
import logging
import random
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Literal
import aiohttp
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field, field_validator
from shapely.geometry import shape
from shapely.validation import explain_validity
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
# One shared aiohttp.ClientSession per worker process — created on startup,
# closed on shutdown via the lifespan handler defined below.
_session: aiohttp.ClientSession | None = None
@asynccontextmanager
async def lifespan(_: FastAPI):
# Reuse TCP connections + DNS cache across every POST so retries do not
# repeat the TLS handshake. on_event("startup"/"shutdown") is deprecated
# in modern FastAPI; the lifespan context manager is the supported path.
global _session
connector = aiohttp.TCPConnector(limit=50, ttl_dns_cache=300)
_session = aiohttp.ClientSession(connector=connector)
try:
yield
finally:
await _session.close()
app = FastAPI(title="Geospatial Webhook Dispatcher", lifespan=lifespan)
# ---------------------------------------------------------------------------
# 1. Canonical GeoEvent schema
# All fields follow RFC 7946 conventions; geometry must be WGS 84 (EPSG:4326).
# ---------------------------------------------------------------------------
class GeoEvent(BaseModel):
event_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
feature_id: str
geometry: dict # RFC 7946 GeoJSON geometry object
change_type: Literal["create", "update", "delete"]
properties: dict = Field(default_factory=dict)
timestamp: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
@field_validator("geometry")
@classmethod
def validate_geojson_geometry(cls, v: dict) -> dict:
"""
Parse through Shapely to catch topology errors before the event
reaches the broker. Self-intersecting polygons, unclosed rings, and
empty geometries all raise ValueError here, not at delivery time.
"""
if not v or "type" not in v or "coordinates" not in v:
raise ValueError("geometry must be a valid GeoJSON geometry object")
try:
geom = shape(v)
except Exception as exc:
raise ValueError(f"Could not parse geometry: {exc}") from exc
if geom.is_empty:
raise ValueError("geometry must not be empty")
if not geom.is_valid:
detail = explain_validity(geom)
raise ValueError(f"Invalid topology: {detail}")
return v
# ---------------------------------------------------------------------------
# 2. HMAC-SHA256 payload signing
# Consumers verify this header as described in RFC 6455 §10 conventions.
# Key is the shared webhook secret; body is the canonical JSON encoding.
# ---------------------------------------------------------------------------
def sign_payload(payload: dict, secret: str) -> str:
"""Return hex digest of HMAC-SHA256 over the canonical JSON body."""
# Compact encoding ensures the signature is stable regardless of
# the client's JSON serializer key ordering.
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
# ---------------------------------------------------------------------------
# 3. Async dispatcher with exponential backoff + full jitter
# The ClientSession is created once per worker in the lifespan handler
# above — never one per request, and never one per retry attempt.
# ---------------------------------------------------------------------------
async def deliver_with_retry(
url: str,
payload: dict,
secret: str,
max_retries: int = 4,
) -> bool:
"""
Attempt delivery up to max_retries times.
Returns True on success, False after exhausting retries (caller routes to DLQ).
Backoff formula: min(cap, 2^attempt) + uniform(0, 1)
Cap at 30 s to avoid very long waits on the last attempt.
"""
headers = {
"Content-Type": "application/json",
"X-Webhook-Signature": f"sha256={sign_payload(payload, secret)}",
"X-Event-ID": payload["event_id"],
"X-Feature-ID": payload["feature_id"],
}
timeout = aiohttp.ClientTimeout(total=10)
for attempt in range(max_retries):
try:
assert _session is not None, "ClientSession not initialised"
async with _session.post(
url, json=payload, headers=headers, timeout=timeout
) as resp:
if resp.status < 300:
log.info(
"delivered event=%s to=%s attempt=%d",
payload["event_id"], url, attempt + 1,
)
return True
# 4xx = client-side error; retrying will not help
if 400 <= resp.status < 500:
log.error(
"client error status=%d event=%s url=%s — routing to DLQ",
resp.status, payload["event_id"], url,
)
return False
# 5xx = server-side transient; fall through to retry
log.warning(
"server error status=%d event=%s attempt=%d",
resp.status, payload["event_id"], attempt + 1,
)
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
log.warning(
"network error event=%s attempt=%d error=%s",
payload["event_id"], attempt + 1, exc,
)
if attempt < max_retries - 1:
# Full jitter: actual delay is uniform in [0, capped_base]
base = min(30.0, 2 ** attempt)
delay = random.uniform(0, base)
await asyncio.sleep(delay)
log.error(
"exhausted retries event=%s url=%s — routing to DLQ",
payload["event_id"], url,
)
return False
async def route_to_dlq(payload: dict, reason: str) -> None:
"""
In production: write to a Redis LPUSH / Kafka topic / database row.
Preserve event_id, feature_id, original payload, failure reason, and
timestamp so the event can be replayed or audited without data loss.
"""
record = {
"dlq_timestamp": datetime.now(timezone.utc).isoformat(),
"reason": reason,
"payload": payload,
}
# Replace this log statement with your broker write:
log.error("DLQ record: %s", json.dumps(record))
# ---------------------------------------------------------------------------
# 4. FastAPI endpoint
# In production, replace the direct POST with a broker consumer loop that
# reads from Redis Streams / Kafka topics and calls deliver_with_retry.
# ---------------------------------------------------------------------------
@app.post("/dispatch")
async def dispatch_event(
event: GeoEvent,
x_target_endpoint: str = Header(..., description="Consumer endpoint URL"),
x_webhook_secret: str = Header(default="change-me"),
) -> dict:
"""
Validate and dispatch a single geospatial event.
Headers
-------
X-Target-Endpoint : full URL of the consumer webhook endpoint
X-Webhook-Secret : shared HMAC secret for payload signing
"""
payload = event.model_dump(mode="json")
delivered = await deliver_with_retry(x_target_endpoint, payload, x_webhook_secret)
if not delivered:
await route_to_dlq(payload, reason="delivery_failed")
raise HTTPException(
status_code=502,
detail=f"Could not deliver event {event.event_id}; routed to DLQ",
)
return {"status": "delivered", "event_id": event.event_id}
Parameter / option reference
| Parameter | Type | Spatial constraint | Default |
|---|---|---|---|
geometry |
dict |
RFC 7946 GeoJSON geometry; coordinates in WGS 84 (EPSG:4326); no self-intersections | required |
feature_id |
str |
Any opaque string; used as broker ordering key | required |
change_type |
"create" | "update" | "delete" |
— | required |
properties |
dict |
Arbitrary key-value pairs; avoid embedding full geometry duplicates here | {} |
max_retries |
int |
— | 4 |
timeout (per attempt) |
float (seconds) |
Keep ≤ 15 s; large polygon serialization can inflate response time | 10 |
X-Webhook-Secret |
str |
Minimum 32 random bytes in production; rotate on breach | "change-me" |
Gotchas and spatial edge cases
-
Coordinate ring orientation. RFC 7946 requires exterior rings to be counter-clockwise and interior rings (holes) to be clockwise. Shapely’s
is_validcheck does not enforce RFC 7946 winding order — it only checks topological validity. Callshapely.geometry.mapping(geom)aftergeom = orient(geom, sign=1.0)fromshapely.opsbefore serializing to JSON. -
CRS mismatch on ingestion. PostGIS may emit geometries in a projected CRS such as EPSG:3857 (Web Mercator). Passing those coordinates directly into a GeoJSON payload violates RFC 7946. Reproject to WGS 84 (EPSG:4326) using
pyproj.Transformerbefore the Pydantic validator runs, as described in Handling Mixed CRS Payloads in Python Event Handlers. -
Precision loss during serialization. Python’s
json.dumpsserializes floats with up to 17 significant digits, butmodel_dump(mode="json")from Pydantic may round coordinates via intermediate float conversion. For high-precision geometries (cadastral surveys, survey-grade GPS), serialize coordinates explicitly to a fixed decimal place (e.g., 7 d.p. ≈ 1 cm accuracy) using a custom JSON encoder rather than relying on default float formatting. -
Empty geometry after transformation. Reprojection of very small or degenerate geometries can produce an empty Shapely result. Always call
geom.is_emptyafter any transformation and reject the event with a descriptive error rather than forwarding an empty geometry downstream. -
HMAC signature drift on large payloads. If the consumer reconstructs the body from a different key ordering than the dispatcher’s
sort_keys=True, the HMAC will not match. Standardize onsort_keys=True, separators=(",", ":")on both sides, and document this contract in your API reference. Mismatches surface as 401s, not 5xxs, so they bypass retry logic — log them explicitly. -
Session lifecycle in async frameworks. Creating a new
aiohttp.ClientSessioninsidedeliver_with_retry(one per attempt) opens a new TCP connection and TLS handshake on every retry. The code above creates one session per worker process via thelifespancontext manager — the deprecated@app.on_event("startup")hook still works but is being phased out, so preferlifespanon new services. When using Celery workers instead of FastAPI, create the session in acelery.signals.worker_process_inithandler and close it inworker_process_shutdown.
Minimal verification snippet
Run this with pytest against a live instance or a respx-mocked session to confirm end-to-end correctness, including signature verification.
# test_dispatcher.py
import asyncio
import hashlib
import hmac
import json
import pytest
from httpx import AsyncClient, ASGITransport
from geospatial_webhook_dispatcher import app, sign_payload
VALID_POINT_GEOMETRY = {
"type": "Point",
"coordinates": [-122.4194, 37.7749], # San Francisco, WGS 84 (EPSG:4326)
}
VALID_POLYGON_GEOMETRY = {
"type": "Polygon",
"coordinates": [[
[-122.42, 37.78],
[-122.40, 37.78],
[-122.40, 37.76],
[-122.42, 37.76],
[-122.42, 37.78], # closed ring
]],
}
@pytest.mark.asyncio
async def test_sign_payload_is_deterministic():
payload = {"event_id": "abc", "feature_id": "f1", "geometry": VALID_POINT_GEOMETRY}
sig1 = sign_payload(payload, "secret")
sig2 = sign_payload(payload, "secret")
assert sig1 == sig2, "Signature must be deterministic"
@pytest.mark.asyncio
async def test_sign_payload_hmac_correct():
payload = {"event_id": "abc", "feature_id": "f1"}
expected_body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
expected = hmac.new(b"secret", expected_body, hashlib.sha256).hexdigest()
assert sign_payload(payload, "secret") == expected
@pytest.mark.asyncio
async def test_dispatch_rejects_invalid_geometry():
self_intersecting = {
"type": "Polygon",
"coordinates": [[
[0, 0], [2, 2], [2, 0], [0, 2], [0, 0], # bowtie — self-intersecting
]],
}
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/dispatch",
json={
"feature_id": "f1",
"geometry": self_intersecting,
"change_type": "update",
},
headers={
"X-Target-Endpoint": "http://example.com/hook",
"X-Webhook-Secret": "test-secret",
},
)
assert resp.status_code == 422, "Self-intersecting polygon must be rejected at validation"
@pytest.mark.asyncio
async def test_dispatch_rejects_missing_geometry_type():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/dispatch",
json={
"feature_id": "f2",
"geometry": {"coordinates": [0, 0]}, # missing "type"
"change_type": "create",
},
headers={
"X-Target-Endpoint": "http://example.com/hook",
"X-Webhook-Secret": "test-secret",
},
)
assert resp.status_code == 422
Frequently asked questions
Why use async dispatch instead of synchronous HTTP for geospatial webhooks?
Spatial payloads — especially polygon and multipolygon geometries — can be large, and topology validation is CPU-bound. Blocking on synchronous requests.post calls ties up workers and pushes backpressure into the ingestion layer, so a slow consumer slows down the database trigger that produced the event. Async delivery with aiohttp decouples validation from delivery: the ingestion path stays write-optimized while a fixed pool of connections fans out POSTs concurrently.
How do I handle CRS mismatches before dispatching a webhook payload?
Normalize every incoming geometry to WGS 84 (EPSG:4326) at the validation layer using pyproj.Transformer before serializing to GeoJSON, since RFC 7946 mandates EPSG:4326. Reject or reproject any geometry whose source CRS differs, and log the original SRID so consumers can audit the transformation. The full reprojection workflow is covered in Handling Mixed CRS Payloads in Python Event Handlers.
When should events go to a dead-letter queue versus be retried?
Retry on transient failures — 5xx responses, timeouts, and connection resets — using the capped exponential backoff with full jitter shown above. Send straight to the dead-letter queue on 4xx client errors (bad endpoint, auth failure, malformed signature), because retrying those only wastes connections, and after max_retries is exhausted for transient errors. Always preserve the full original payload plus failure metadata in the DLQ so the event can be replayed without data loss.
Related
- Feature Change Triggers — the parent topic covering when and why to fire events on spatial mutations
- Implementing At-Least-Once Delivery for GIS Webhooks — retry guarantees, consumer acknowledgement patterns, and queue durability
- Generating Deterministic Idempotency Keys for GeoJSON Events — how to derive stable
event_idvalues from geometry hashes to prevent duplicate processing