Parsing GeoJSON Webhooks with FastAPI and Pydantic
Define RFC 7946-compliant Pydantic v2 models with extra="forbid", verify the HMAC signature over the raw request body, then call model_validate_json so FastAPI rejects malformed GeoJSON with a structured 422 before any spatial logic runs. This how-to sits under GeoJSON to Protobuf Mapping, part of Spatial Payload Routing & Parsing.
When to use this pattern
Schema enforcement at the HTTP edge is worth the extra model code when malformed geometry would otherwise propagate silently:
- You ingest GeoJSON from third parties or untrusted clients — partner integrations, mobile clients, or IoT gateways — where a single missing
typediscriminator or a swapped longitude/latitude pair can corrupt a spatial index downstream. - You need deterministic, machine-readable rejection: a
422with field-level error paths beats a500traceback when a sender debugs why their payload bounced, and it keeps malformed coordinates out of Geometry Validation Pipelines further along. - Your endpoint feeds a binary serializer or async fan-out — for example, converting validated features as covered in GeoJSON to Protobuf Mapping — where the consumer assumes the input is already structurally sound.
If you are only logging payloads or your producer is fully trusted and inside your own trust boundary, a permissive dict[str, Any] parse may be enough. Reserve strict modeling for ingress points where “is this even valid GeoJSON?” is a question you cannot afford to answer downstream.
Validation flow
The diagram below shows the fail-fast order. Cheap checks run first (signature over raw bytes, then structural parse); the spatial bound and ring checks run inside the model only once the JSON is well-formed. Any failure short-circuits to a typed HTTP error before the payload reaches your processor.
Complete runnable code block
The handler below is self-contained. It models the GeoJSON object graph with discriminated unions, enforces WGS 84 / EPSG:4326 coordinate bounds and closed polygon rings inside the model, verifies an HMAC-SHA256 signature over the raw bytes, and returns 202 with a fast hand-off to an async processor. Because the spec mandates EPSG:4326 with longitude before latitude, the bound checks assume that order.
import hashlib
import hmac
from typing import Annotated, Any, Literal, Union
from fastapi import FastAPI, Header, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
app = FastAPI(title="GeoJSON Webhook Ingestor")
WEBHOOK_SECRET = b"load-from-secrets-manager-not-source-control"
# --- Coordinate helpers (RFC 7946 mandates EPSG:4326, longitude first) ---
def _check_position(pos: list[float]) -> None:
# A position is [lon, lat] or [lon, lat, elevation]; only the first two are bounded.
if len(pos) < 2:
raise ValueError("position must have at least longitude and latitude")
lon, lat = pos[0], pos[1]
if not (-180.0 <= lon <= 180.0):
raise ValueError(f"longitude {lon} outside [-180, 180]")
if not (-90.0 <= lat <= 90.0):
raise ValueError(f"latitude {lat} outside [-90, 90]")
def _walk_positions(coords: Any) -> None:
# Recurse into the nested coordinate array until we hit [lon, lat, ...] positions.
if coords and isinstance(coords[0], (int, float)):
_check_position(coords)
else:
for child in coords:
_walk_positions(child)
# --- Pydantic v2 GeoJSON models ---
class Point(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["Point"]
coordinates: list[float]
class Polygon(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["Polygon"]
# Polygon = array of linear rings; each ring is an array of positions.
coordinates: list[list[list[float]]]
@model_validator(mode="after")
def rings_must_close(self) -> "Polygon":
for ring in self.coordinates:
if len(ring) < 4:
raise ValueError("a linear ring needs at least 4 positions")
if ring[0] != ring[-1]:
raise ValueError("polygon ring is not closed (first != last position)")
return self
# Discriminated union keeps error messages precise per geometry type.
Geometry = Annotated[Union[Point, Polygon], Field(discriminator="type")]
class Feature(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["Feature"]
geometry: Geometry
properties: dict[str, Any] | None = None
id: str | int | None = None
@model_validator(mode="after")
def coordinates_in_bounds(self) -> "Feature":
_walk_positions(self.geometry.coordinates)
return self
class FeatureCollection(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["FeatureCollection"]
features: list[Feature] = Field(min_length=1)
class WebhookPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
event_type: str
data: Feature | FeatureCollection
# --- Security ---
def verify_signature(raw: bytes, signature: str) -> bool:
expected = hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# --- Route ---
@app.post("/webhooks/geojson", status_code=status.HTTP_202_ACCEPTED)
async def ingest(
request: Request,
x_webhook_signature: str = Header(..., alias="X-Webhook-Signature"),
):
# 1. Read raw bytes ONCE; the signature is computed over these exact bytes.
raw = await request.body()
if not verify_signature(raw, x_webhook_signature):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid signature")
# 2. Parse the same bytes into the typed model; structured 422 on any violation.
try:
payload = WebhookPayload.model_validate_json(raw)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, exc.errors())
# 3. Acknowledge fast; offload heavy spatial work to a queue or executor.
await route_downstream(payload)
return {"status": "accepted", "event_type": payload.event_type}
async def route_downstream(payload: WebhookPayload) -> None:
# Hand off to PostGIS, a broker, or a protobuf serializer here.
...
Parameter / option reference table
The settings below are the ones that change correctness, not just style. Spatial constraints assume RFC 7946 (EPSG:4326, longitude first).
| Option | Type | Spatial constraint / effect | Default |
|---|---|---|---|
ConfigDict(extra="forbid") |
bool flag | Rejects unknown keys (e.g. a typo’d geometery) instead of silently dropping them |
extra="ignore" |
Field(discriminator="type") |
union discriminator | Routes each geometry to the right model so errors name the actual type, not “none matched” | none |
Field(min_length=1) on features |
int | A FeatureCollection with zero features is structurally legal in RFC 7946 but usually a producer bug |
unbounded |
model_validate_json(raw) |
classmethod | Parses bytes directly; skips a json.loads round-trip and preserves byte-exact signature input |
n/a |
hmac.compare_digest |
function | Constant-time comparison; prevents timing side-channels that leak the secret | use == (unsafe) |
| lon bound | float range | -180.0 <= lon <= 180.0; values outside indicate swapped lat/lon or a non-EPSG:4326 source |
none |
| lat bound | float range | -90.0 <= lat <= 90.0; a value of 200 is a classic swapped-axis symptom |
none |
| ring closure | list check | First and last position of each linear ring must be equal | not enforced |
Gotchas and spatial edge cases
- Swapped longitude and latitude. The single most common bug. RFC 7946 mandates
[longitude, latitude], but many producers (and most people) say “lat, lon”. APointat[40.7, -74.0]will pass naive parsing yet place New York in the Indian Ocean. The bound checks above catch only egregious swaps (lat > 90); for plausible mid-latitude values you need a sanity check against an expected region. - Coordinate precision loss before signing. If you round coordinates before computing or verifying the HMAC, the recomputed digest will not match. Sign the raw bytes exactly as received and round only after validation, never before.
- Ring orientation is not validity. RFC 7946 prefers right-hand-rule winding (exterior counter-clockwise, holes clockwise) but most parsers accept either. Pydantic checks closure, not winding. If a downstream consumer assumes a winding order, normalize it explicitly with Shapely’s
orient()after parsing. extra="ignore"hides typos. Withoutextra="forbid", a payload sendinggeometery(misspelled) parses withgeometry=Noneand no error. Forbidding extras turns that into a loud422.- Unbounded coordinate arrays are a denial-of-service surface. A single
Polygonwith millions of vertices will exhaust memory during parse. Cap request body size at the gateway and consider a vertex-count guard before handing the geometry to Async Processing for Heavy Geometries. - CRS assumptions on merge. This handler assumes EPSG:4326. If any producer emits EPSG:3857 or a local grid, the bound checks will reject legitimate data or — worse — pass garbage. Normalize upstream per CRS Normalization Strategies before this endpoint, or carry an explicit CRS field and transform inside the validator.
Minimal verification snippet
Run the following with pytest. It signs a valid payload, asserts a 202, then mutates the body and asserts the typed rejections — no live server required, thanks to FastAPI’s TestClient.
import hashlib
import hmac
import json
from fastapi.testclient import TestClient
from app import WEBHOOK_SECRET, app # import from the module above
client = TestClient(app)
def _sign(body: bytes) -> dict[str, str]:
sig = hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
return {"X-Webhook-Signature": sig}
VALID = {
"event_type": "feature.created",
"data": {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-74.006, 40.7128]},
"properties": {"name": "NYC"},
},
}
def test_valid_payload_accepted():
body = json.dumps(VALID).encode()
resp = client.post("/webhooks/geojson", content=body, headers=_sign(body))
assert resp.status_code == 202
assert resp.json()["event_type"] == "feature.created"
def test_out_of_bounds_latitude_rejected():
bad = json.loads(json.dumps(VALID))
bad["data"]["geometry"]["coordinates"] = [-74.006, 200.0] # lat > 90
body = json.dumps(bad).encode()
resp = client.post("/webhooks/geojson", content=body, headers=_sign(body))
assert resp.status_code == 422
def test_bad_signature_rejected():
body = json.dumps(VALID).encode()
resp = client.post(
"/webhooks/geojson", content=body,
headers={"X-Webhook-Signature": "deadbeef"},
)
assert resp.status_code == 401
A passing run confirms three guarantees at once: well-formed GeoJSON is accepted, an out-of-range coordinate is rejected with 422 before any processing, and a forged signature never reaches the parser.
Related
- GeoJSON to Protobuf Mapping — turn the validated features from this handler into compact binary messages for high-throughput routing.
- Optimizing Async Geometry Parsing with asyncio — offload heavy validated geometries to a process pool so ingestion stays non-blocking.
- Spatial Payload Routing & Parsing — the broader architecture this ingress layer feeds into.