Sensor Data Routing Patterns for Geospatial Webhooks
Raw telemetry from spatial sensors rarely flows directly to consumers — it passes through a deterministic routing layer that validates, classifies, and dispatches payloads based on geographic boundaries, attribute thresholds, and subscriber rules. This page is part of Core Event Fundamentals & Architecture and covers how to build that routing layer in Python from ingestion through broker dispatch.
Prerequisites
Before deploying a spatial routing pipeline, confirm your environment meets these requirements:
Architecture Blueprint
The routing pipeline is a five-stage, stage-isolated sequence. Each stage enforces strict contracts so individual phases can be scaled or replaced independently.
Step-by-Step Implementation
Step 1 — Secure Ingestion and Schema Validation
Purpose: Reject malformed and tampered payloads before they consume any downstream compute.
Raw sensor payloads arrive via HTTP POST or MQTT-to-HTTP bridge. The ingress layer validates JSON structure, verifies the cryptographic signature (HMAC-SHA256 or Ed25519 — the signing strategies are covered in detail at Securing Webhook Endpoints with Spatial Token Validation), and extracts routing metadata: device_id, epoch timestamp, coordinate reference system (CRS), and firmware version. Invalid payloads go straight to a dead-letter queue; spatial operations on corrupt geometries cause silent routing failures or broker poisoning.
import hashlib
import hmac
from fastapi import FastAPI, Request, HTTPException
from pydantic import BaseModel, field_validator, model_validator
from shapely.geometry import shape
from shapely.validation import make_valid
import pyproj
app = FastAPI()
WEBHOOK_SECRET = b"your-hmac-secret" # load from env in production
class SensorPayload(BaseModel):
device_id: str
timestamp_utc: float # Unix epoch, seconds
crs: str = "EPSG:4326" # default; must be EPSG code string
geometry: dict # raw GeoJSON geometry object
properties: dict = {}
@field_validator("crs")
@classmethod
def crs_must_be_epsg(cls, v: str) -> str:
if not v.upper().startswith("EPSG:"):
raise ValueError("CRS must be an EPSG code, e.g. EPSG:4326")
return v.upper()
@model_validator(mode="after")
def geometry_must_be_valid(self) -> "SensorPayload":
try:
geom = shape(self.geometry)
except Exception as exc:
raise ValueError(f"GeoJSON geometry parse error: {exc}") from exc
if not geom.is_valid:
geom = make_valid(geom) # attempt automatic repair
if geom.is_empty:
raise ValueError("Geometry is empty after validation")
return self
def verify_hmac(body: bytes, signature: str) -> bool:
expected = hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.removeprefix("sha256="))
@app.post("/ingest")
async def ingest(request: Request):
body = await request.body()
sig = request.headers.get("X-Hub-Signature-256", "")
if not verify_hmac(body, sig):
raise HTTPException(status_code=401, detail="Invalid signature")
try:
payload = SensorPayload.model_validate_json(body)
except Exception as exc:
# quarantine to dead-letter queue instead of returning 400
await quarantine(body, reason=str(exc))
return {"status": "quarantined"}
return await route_payload(payload)
Step 2 — Spatial and Attribute Classification
Purpose: Determine which registered zones and consumers the event belongs to.
The router evaluates the reprojected point or polygon against registered spatial zones (geofences, administrative boundaries, wildfire risk polygons) and attribute filters (device type, state transitions, threshold crossings). This stage intersects with Feature Change Triggers when routing decisions depend on delta detection rather than absolute state. Spatial indexing with an R-tree is mandatory; brute-force intersection testing degrades linearly with zone count and introduces unacceptable tail latency at production scale. Topology checks and geometry repair belong here, following the same patterns described in Geometry Validation Pipelines.
from shapely.strtree import STRtree
from shapely.geometry import shape, Point
import pyproj
# Build once at startup; refresh when zone registry changes
TRANSFORMER_CACHE: dict[str, pyproj.Transformer] = {}
def get_transformer(source_crs: str) -> pyproj.Transformer:
if source_crs not in TRANSFORMER_CACHE:
TRANSFORMER_CACHE[source_crs] = pyproj.Transformer.from_crs(
source_crs, "EPSG:4326", always_xy=True
)
return TRANSFORMER_CACHE[source_crs]
class ZoneIndex:
"""R-tree-backed index of registered spatial zones."""
def __init__(self, zones: list[dict]):
self._zones = zones
self._geoms = [shape(z["geometry"]) for z in zones]
self._tree = STRtree(self._geoms)
def classify(self, geom) -> list[dict]:
candidate_indices = self._tree.query(geom)
return [
self._zones[i]
for i in candidate_indices
if self._geoms[i].intersects(geom)
]
def reproject_geometry(payload: SensorPayload):
"""Return a Shapely geometry in EPSG:4326."""
geom = shape(payload.geometry)
if payload.crs == "EPSG:4326":
return geom
transformer = get_transformer(payload.crs)
return transform(transformer.transform, geom)
async def classify_payload(payload: SensorPayload, index: ZoneIndex) -> list[dict]:
geom_wgs84 = reproject_geometry(payload)
matched_zones = index.classify(geom_wgs84)
# Attribute filtering — e.g. temperature threshold + zone membership
props = payload.properties
return [
z for z in matched_zones
if z.get("device_type") in (props.get("device_type"), None)
]
Step 3 — Route Resolution and Fan-Out
Purpose: Translate matched zones into a dispatch list with partition keys and TTL metadata.
A single sensor event may route to multiple consumers (fan-out) or be suppressed entirely if it matches an exclusion rule or a deduplication window. Suppression prevents alert fatigue and cuts broker load during high-frequency telemetry bursts — the overlap-based suppression approach is detailed in Spatial Overlap Deduplication. For idempotency key generation — essential when a consumer group receives the same event more than once — follow the approach in Event Key Generation for Spatial Data.
import hashlib, json, time
from dataclasses import dataclass
@dataclass
class DispatchEnvelope:
topic: str
partition_key: str # device_id for ordering; H3 cell for spatial locality
payload: dict
event_id: str
ttl_seconds: int = 300
def build_event_id(payload: SensorPayload) -> str:
"""Deterministic SHA-256 event ID for idempotency tracking."""
canonical = json.dumps(
{"device_id": payload.device_id,
"timestamp_utc": payload.timestamp_utc,
"geometry": payload.geometry},
sort_keys=True
).encode()
return hashlib.sha256(canonical).hexdigest()
def resolve_routes(
payload: SensorPayload,
matched_zones: list[dict],
dedup_window_seconds: int = 5,
seen_ids: set[str] | None = None,
) -> list[DispatchEnvelope]:
seen_ids = seen_ids or set()
event_id = build_event_id(payload)
if event_id in seen_ids:
return [] # suppressed — duplicate within dedup window
envelopes = []
for zone in matched_zones:
envelopes.append(DispatchEnvelope(
topic=zone["topic"],
partition_key=payload.device_id,
payload={"event_id": event_id,
"device_id": payload.device_id,
"timestamp_utc": payload.timestamp_utc,
"geometry": payload.geometry,
"zone_id": zone["id"],
"properties": payload.properties},
event_id=event_id,
))
seen_ids.add(event_id)
return envelopes
Step 4 — Asynchronous Broker Dispatch
Purpose: Push resolved routes to a durable message broker without blocking the ingress worker.
Partitioning strategy directly impacts consumer scaling and spatial locality. Hash-based partitioning on device_id ensures ordered delivery per sensor. Spatial partitioning by H3 cell (resolution 5–7) optimizes downstream Tile Update Event Pipelines and regional analytics. Configure explicit retention policies, consumer group offsets, and backpressure thresholds; unbounded broker topics exhaust memory during telemetry spikes. For high-frequency streams where JSON serialization overhead is measurable, consider the payload format tradeoffs in GeoJSON to Protobuf Mapping.
The choice of partition key is a one-way door at production scale — it determines whether consumers can be scaled by sensor or by geography, and the two strategies move events to different partitions for the same input stream:
import asyncio
import aioredis
redis: aioredis.Redis | None = None
async def get_redis() -> aioredis.Redis:
global redis
if redis is None:
redis = await aioredis.from_url("redis://localhost:6379", decode_responses=False)
return redis
async def dispatch_to_broker(envelopes: list[DispatchEnvelope]) -> None:
r = await get_redis()
async with r.pipeline(transaction=False) as pipe:
for env in envelopes:
serialized = json.dumps(env.payload).encode()
# XADD to Redis Stream; maxlen trims to ~100k events per topic
pipe.xadd(
env.topic,
{"data": serialized, "partition_key": env.partition_key},
maxlen=100_000,
approximate=True,
)
await pipe.execute()
async def route_payload(payload: SensorPayload) -> dict:
# ZoneIndex loaded at startup; passed via app state in production
zone_index: ZoneIndex = app.state.zone_index
matched = await classify_payload(payload, zone_index)
envelopes = resolve_routes(payload, matched)
if envelopes:
asyncio.create_task(dispatch_to_broker(envelopes))
return {"status": "accepted", "routes": len(envelopes)}
Step 5 — Downstream Consumption and State Sync
Purpose: Pull from broker partitions, apply idempotent writes, and update application state.
Consumers pull from Redis Streams or Kafka partitions, deserialize payloads, and update PostGIS or an in-memory spatial cache. They must implement idempotent write patterns because network partitions or broker retries will deliver duplicate events. State synchronization should use optimistic concurrency control or version vectors rather than blind overwrites; when concurrent writes produce conflicting spatial state, the resolution strategies in Conflict Resolution Strategies apply. For full implementation details on retry backoff, idempotency key tracking in Redis, and dead-letter routing, see Implementing at-least-once delivery for GIS webhooks.
import asyncio, json
import aioredis
import asyncpg
CONSUMER_GROUP = "spatial-consumers"
CONSUMER_NAME = "worker-1"
PROCESSED_KEY = "processed_event_ids" # Redis SET for idempotency tracking
async def consume_stream(stream: str, db_pool: asyncpg.Pool) -> None:
r = await get_redis()
# Create group at stream start; ignore if already exists
try:
await r.xgroup_create(stream, CONSUMER_GROUP, id="0", mkstream=True)
except aioredis.ResponseError:
pass
while True:
messages = await r.xreadgroup(
CONSUMER_GROUP, CONSUMER_NAME,
streams={stream: ">"},
count=50, block=1000,
)
for _, entries in (messages or []):
for msg_id, fields in entries:
event = json.loads(fields[b"data"])
event_id = event["event_id"]
already_seen = await r.sismember(PROCESSED_KEY, event_id)
if already_seen:
await r.xack(stream, CONSUMER_GROUP, msg_id)
continue
try:
await write_to_postgis(db_pool, event)
await r.sadd(PROCESSED_KEY, event_id)
await r.expire(PROCESSED_KEY, 86_400) # 24 h TTL
await r.xack(stream, CONSUMER_GROUP, msg_id)
except Exception as exc:
# Leave unacknowledged; broker retries on next read
print(f"Consumer error for {event_id}: {exc}")
async def write_to_postgis(pool: asyncpg.Pool, event: dict) -> None:
geom_json = json.dumps(event["geometry"])
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO sensor_events (event_id, device_id, geom, recorded_at)
VALUES ($1, $2, ST_SetSRID(ST_GeomFromGeoJSON($3), 4326), to_timestamp($4))
ON CONFLICT (event_id) DO NOTHING
""",
event["event_id"], event["device_id"], geom_json, event["timestamp_utc"],
)
Spatial Validation and Error Handling
Geometry topology checks must run inside the ingestion model validator (see Step 1), not inside the spatial classification worker. The classification worker should receive only pre-validated, EPSG:4326 geometries. CRS alignment code belongs at the boundary between ingestion and classification — never inside the broker consumer.
When Pydantic rejects a payload, quarantine the raw bytes with a structured reason string, the originating device_id, and the wall-clock timestamp. This gives operators enough context to replay the event after a firmware fix without re-ingesting the entire stream.
For mixed-CRS streams — common when a device fleet spans multiple manufacturers — normalize all incoming coordinates to EPSG:4326 at the ingestion boundary using pyproj.Transformer with always_xy=True. Storing EPSG codes in the event envelope rather than inferring them from coordinate magnitude prevents silent axis-order bugs. Full normalization strategies are covered in CRS Normalization Strategies, and the asyncio-native patterns for processing geometrically heavy payloads without blocking the event loop appear in Async Processing for Heavy Geometries.
Retry, Backoff, and Delivery Guarantees
At-least-once delivery is the industry standard for geospatial telemetry: no event is lost, but consumers must handle duplicates. Exactly-once delivery is rarely achievable across distributed spatial systems due to network partitions and broker leader elections.
The exponential backoff with jitter pattern prevents retry thundering-herds when a PostGIS write or geometry validation fails:
import asyncio, random
async def retry_with_backoff(coro, max_attempts: int = 5, base_delay: float = 0.5):
"""Exponential backoff with full jitter for spatial write operations."""
for attempt in range(max_attempts):
try:
return await coro()
except Exception as exc:
if attempt == max_attempts - 1:
raise
delay = base_delay * (2 ** attempt) + random.uniform(0, base_delay)
await asyncio.sleep(delay)
Use deterministic event IDs (SHA-256 of device_id + timestamp + geometry hash) to track processed events in Redis. When a duplicate arrives, the consumer checks membership in a Redis SET, skips processing, and returns a 200 OK to prevent broker redelivery storms. The Cache-Backed Idempotency Checks pattern covers the Redis SET expiry strategy and handling hash collisions for high-cardinality device fleets.
Verification
Run this integration test harness against a local Redis + FastAPI stack to confirm the full pipeline works end-to-end:
import pytest, asyncio, json, hashlib, hmac, httpx
ENDPOINT = "http://localhost:8000/ingest"
SECRET = b"your-hmac-secret"
def sign(body: bytes) -> str:
return "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
VALID_PAYLOAD = {
"device_id": "sensor-001",
"timestamp_utc": 1700000000.0,
"crs": "EPSG:4326",
"geometry": {"type": "Point", "coordinates": [-122.4194, 37.7749]},
"properties": {"temperature_c": 24.5},
}
@pytest.mark.asyncio
async def test_valid_payload_accepted():
body = json.dumps(VALID_PAYLOAD).encode()
async with httpx.AsyncClient() as client:
resp = await client.post(
ENDPOINT, content=body,
headers={"X-Hub-Signature-256": sign(body),
"Content-Type": "application/json"}
)
assert resp.status_code == 200
assert resp.json()["status"] == "accepted"
@pytest.mark.asyncio
async def test_invalid_signature_rejected():
body = json.dumps(VALID_PAYLOAD).encode()
async with httpx.AsyncClient() as client:
resp = await client.post(
ENDPOINT, content=body,
headers={"X-Hub-Signature-256": "sha256=bad",
"Content-Type": "application/json"}
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_invalid_crs_quarantined():
payload = {**VALID_PAYLOAD, "crs": "OSGB36"} # missing EPSG: prefix
body = json.dumps(payload).encode()
async with httpx.AsyncClient() as client:
resp = await client.post(
ENDPOINT, content=body,
headers={"X-Hub-Signature-256": sign(body),
"Content-Type": "application/json"}
)
assert resp.json()["status"] == "quarantined"
Troubleshooting
| Symptom | Likely spatial cause | Fix |
|---|---|---|
| P99 ingestion latency spikes above 500 ms | Brute-force polygon intersection on large zone registry | Replace linear scan with STRtree.query() R-tree index |
| Consumers report duplicate geometry writes | Missing idempotency check before PostGIS INSERT |
Add ON CONFLICT (event_id) DO NOTHING + Redis SET membership check |
| Broker queue depth grows unbounded | Consumers blocked on slow geometry validation | Move geometry validation to ingestion worker; consumers receive pre-validated payloads |
| Silent coordinate flip (lat/lon swapped) | pyproj axis order not forced to XY |
Use always_xy=True in Transformer.from_crs() |
Dead-letter queue fills with "Geometry is empty" |
Device firmware emits null coordinate arrays |
Add null-coordinate guard in Pydantic @model_validator before calling shape() |
| Consumer lag grows per geographic shard | Partition skew — one H3 cell has 10× events of others | Lower H3 resolution (larger cells) or add a secondary partition key for high-density areas |
FAQ
When should I partition by H3 cell instead of device ID?
Use H3 partitioning when downstream consumers are regionally scoped — tile renderers, per-city alerting services, or geographic shards of a PostGIS database. Use device-ID partitioning when ordering guarantees per sensor matter more than spatial locality, such as time-series telemetry streams where out-of-order events corrupt state. You can combine both by using device_id as the primary partition key and including the H3 cell ID in the event envelope for downstream routing hints.
How do I handle sensors that report in a CRS other than WGS 84 (EPSG:4326)?
Reproject at the ingestion boundary, before spatial classification, using pyproj.Transformer.from_crs(source, "EPSG:4326", always_xy=True). Store the original CRS in the event envelope as a metadata field so consumers can audit provenance. Route exclusively on EPSG:4326 coordinates. This is the same normalization strategy described in CRS Normalization Strategies.
Can I skip the message broker and route synchronously from FastAPI to consumers?
Only for very low throughput (under ~50 events per second) with a single consumer. Without a broker, a consumer crash silently drops events and spatial mutations are lost with no replay capability. For production IoT feeds, always interpose a durable broker. Redis Streams is the lowest-friction option; Kafka is the right choice when you need event replay, consumer group partitioning, or retention beyond 24 hours.
What queue depth limit should I use to apply backpressure at the ingress?
A practical starting point is 10,000 in-flight events per ingress worker. Monitor P99 ingestion latency and drop rate using OpenTelemetry counters. When P99 exceeds 300 ms or drop rate exceeds 0.05%, tighten the limit or scale out workers. Return HTTP 429 with a Retry-After header so upstream senders back off gracefully rather than hammering the endpoint.
Related
- Core Event Fundamentals & Architecture — the parent section covering spatial event modeling, delivery semantics, and pipeline architecture
- Implementing at-least-once delivery for GIS webhooks — deep-dive on retry backoff, idempotency key storage, and dead-letter routing
- Feature Change Triggers — how to detect and dispatch spatial mutations from PostGIS and CDC connectors
- Tile Update Event Pipelines — consuming routed sensor events to invalidate and regenerate raster and vector tiles
- CRS Normalization Strategies — normalizing mixed-CRS payloads before they enter the routing layer