Using Redis to Cache Spatial Webhook Signatures
Compute an HMAC-SHA256 signature of the canonicalized GeoJSON payload, call SET key 1 NX EX <ttl> in Redis, and skip processing if the command returns nil — the entire duplicate check completes in one atomic round-trip at under 5 ms. This how-to belongs to Cache-Backed Idempotency Checks, which is part of the wider Idempotency & Spatial Deduplication work on exactly-once semantics for geospatial event streams.
When to Use This Pattern
Use Redis signature caching specifically when:
- Your geospatial webhook provider does not guarantee single delivery and retries on HTTP
5xxor timeout, producing identical GeoJSON or WKT bodies milliseconds to hours apart. - Processing latency is dominated by downstream spatial joins, PostGIS writes, or tile cache invalidations — a sub-millisecond Redis check is worth far more here than on cheap CRUD endpoints.
- You need horizontal scalability: multiple webhook receiver pods must share deduplication state without a central lock or database row-level locking overhead.
For architectures where the same event must be deduplicated across independent microservices rather than at ingress, pair this approach with an outbox pattern; Redis alone does not span transaction boundaries.
Flow: From HTTP Ingress to Conditional Processing
The diagram below shows how a spatial webhook travels from provider delivery through signature caching to either processing or discard.
Complete Runnable Implementation
The snippet below is self-contained: normalize, sign, check, and branch in one function. The normalize_geojson step is critical — without it, floating-point drift in coordinate arrays causes logically identical retries to produce different signatures and bypass the cache. For a deeper look at this canonicalization problem, see Generating Deterministic Idempotency Keys for GeoJSON Events.
import hashlib
import hmac
import json
import os
from typing import Any
import redis
from redis.exceptions import RedisError
# ── Configuration ────────────────────────────────────────────────────────────
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "change-me")
# Set to 20–30 % above the provider's max redelivery window.
# E.g. provider retries for 24 h → use 108 000 s (30 h).
SIGNATURE_TTL = int(os.getenv("SIGNATURE_TTL", "108000"))
# Thread-safe connection pool; socket_timeout evicts blocked calls quickly.
_redis = redis.Redis.from_url(
REDIS_URL,
decode_responses=True,
socket_timeout=2,
socket_connect_timeout=2,
)
# ── Spatial canonicalization ─────────────────────────────────────────────────
_TRANSIENT_KEYS = frozenset({"_meta", "received_at", "request_id", "delivery_attempt"})
def _round_coords(value: Any, precision: int = 6) -> Any:
"""Recursively round floats inside GeoJSON coordinate arrays (RFC 7946
recommends 6 decimal places ≈ 0.11 m accuracy in EPSG:4326)."""
if isinstance(value, float):
return round(value, precision)
if isinstance(value, list):
return [_round_coords(v, precision) for v in value]
if isinstance(value, dict):
return {k: _round_coords(v, precision) for k, v in value.items()}
return value
def normalize_geojson(payload: dict[str, Any]) -> str:
"""Return a canonical UTF-8 string suitable for deterministic hashing.
Strips transient provider fields, rounds coordinate floats, and
enforces sort_keys so key insertion order cannot affect the digest.
"""
clean = {k: v for k, v in payload.items() if k not in _TRANSIENT_KEYS}
clean = _round_coords(clean)
# separators=(",", ":") removes all optional whitespace
return json.dumps(clean, sort_keys=True, separators=(",", ":"))
# ── Signature computation ────────────────────────────────────────────────────
def compute_signature(canonical: str) -> str:
"""HMAC-SHA256 over the canonical payload string."""
return hmac.new(
WEBHOOK_SECRET.encode(),
canonical.encode(),
hashlib.sha256,
).hexdigest()
# ── Idempotency check ────────────────────────────────────────────────────────
def is_new_spatial_event(payload: dict[str, Any]) -> bool:
"""Return True if this event is new and should be processed.
Uses a single atomic SET NX EX round-trip — no separate GET needed.
Fails open on RedisError so a cache outage never stalls the pipeline;
downstream spatial DB constraints remain the last-resort safety net.
"""
canonical = normalize_geojson(payload)
sig = compute_signature(canonical)
cache_key = f"swh:sig:{sig}" # namespace prefix avoids key collisions
try:
# SET returns True (key was absent, now set) or None (key existed).
result = _redis.set(cache_key, "1", nx=True, ex=SIGNATURE_TTL)
return result is True
except RedisError as exc:
# Log for alerting but do not block ingestion.
print(f"[idempotency] Redis unavailable, failing open: {exc}")
return True
# ── Example webhook handler (FastAPI) ────────────────────────────────────────
# from fastapi import FastAPI, Request, Response
# app = FastAPI()
#
# @app.post("/webhook/spatial")
# async def receive_spatial_event(request: Request):
# payload = await request.json()
# if not is_new_spatial_event(payload):
# return Response(status_code=200, content="duplicate ignored")
# # Hand off to your spatial processing pipeline here.
# return {"status": "accepted"}
Parameter Reference
| Parameter | Type | Default | Spatial constraint |
|---|---|---|---|
REDIS_URL |
str |
redis://localhost:6379/0 |
Use a dedicated DB index (e.g. /1) to isolate signature keys from application data |
WEBHOOK_SECRET |
str |
(required) | Minimum 32 bytes of entropy; rotate via environment variable without redeploying code |
SIGNATURE_TTL |
int (seconds) |
108000 (30 h) |
Must exceed provider max redelivery window; monitor TTL drift with Redis keyspace notifications |
precision in _round_coords |
int |
6 |
6 decimals ≈ 0.11 m at EPSG:4326; raise to 7–8 for sub-centimetre survey payloads |
cache_key prefix |
str |
swh:sig: |
Namespace all signature keys so you can SCAN MATCH swh:sig:* for monitoring |
socket_timeout |
float (seconds) |
2 |
Tune down to 0.5 in latency-sensitive ingestion paths; too low causes false-positive RedisError on slow networks |
Gotchas and Spatial Edge Cases
-
Coordinate ring orientation drift. RFC 7946 mandates counter-clockwise winding for exterior rings, but several providers (and PostGIS
ST_AsGeoJSON) do not enforce this. Two deliveries of the same polygon with opposite winding produce different canonical strings and different signatures. Runshapely.geometry.shape(payload["geometry"]).buffer(0)to normalize topology before hashing when winding consistency cannot be guaranteed. -
Precision loss in
float→str→floatround-trips. If your ingress framework parses JSON as Pythonfloatbefore passing to the handler, values like-122.4194155may already carry IEEE-754 drift. The_round_coordshelper fixes this, but apply it after deserialization, not before — rounding the raw string is a no-op. -
Mixed CRS payloads colliding after normalization. A feature in EPSG:3857 (Web Mercator) and the same feature re-projected to EPSG:4326 (WGS84) will have very different coordinate values, so they will not collide in Redis — but they should be treated as duplicates if they represent the same real-world event. Always normalize to a single CRS before hashing; see Handling Mixed CRS Payloads in Python Event Handlers for a projection pipeline.
-
Provider-added timestamp fields in the geometry. Some IoT and drone telemetry providers embed
"timestamp"inside thegeometryobject (non-standard GeoJSON). Extend_TRANSIENT_KEYSor add a recursive filter to strip these before hashing, otherwise each delivery attempt gets a unique signature and the cache is useless. -
Redis Cluster hash slot boundary for key distribution. In Redis Cluster mode, all
swh:sig:*keys hash to different slots based on the full key, distributing evenly. This is correct behaviour for signature caching. Do not add a hash tag ({swh}) unless you need multi-key transactions on the same slot — and there are none here. -
TTL shorter than the delivery retry window. If
SIGNATURE_TTLexpires before the provider stops retrying, the Redis key disappears and the duplicate is processed again. Always add a buffer of at least 30 % above the provider’s stated retry window, and monitorOBJECT IDLETIMEon sample keys.
Minimal Verification Snippet
Run this against a local Redis instance to confirm the atomic check-and-set behaves correctly for both new events and retries:
import pytest
from unittest.mock import patch, MagicMock
# Isolate the module under test from a live Redis connection.
from your_module import is_new_spatial_event
SAMPLE_PAYLOAD = {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-122.4194155, 37.7749295]},
"properties": {"sensor_id": "gps-42"},
}
def _make_redis_mock(set_returns):
mock = MagicMock()
mock.set.side_effect = set_returns
return mock
def test_new_event_returns_true():
with patch("your_module._redis", _make_redis_mock([True])):
assert is_new_spatial_event(SAMPLE_PAYLOAD) is True
def test_duplicate_event_returns_false():
# First call claims the key; second call returns None (key already exists).
with patch("your_module._redis", _make_redis_mock([True, None])):
assert is_new_spatial_event(SAMPLE_PAYLOAD) is True
assert is_new_spatial_event(SAMPLE_PAYLOAD) is False
def test_transient_fields_stripped_before_hashing():
"""Events identical except for 'received_at' must map to the same key."""
payload_a = {**SAMPLE_PAYLOAD, "received_at": "2026-01-01T00:00:00Z"}
payload_b = {**SAMPLE_PAYLOAD, "received_at": "2026-01-01T00:05:00Z"}
with patch("your_module._redis", _make_redis_mock([True, None])):
assert is_new_spatial_event(payload_a) is True
assert is_new_spatial_event(payload_b) is False # same spatial content
def test_redis_unavailable_fails_open():
from redis.exceptions import RedisError
mock = MagicMock()
mock.set.side_effect = RedisError("connection refused")
with patch("your_module._redis", mock):
# Fail-open: event must be processed even when Redis is down.
assert is_new_spatial_event(SAMPLE_PAYLOAD) is True
Frequently Asked Questions
What TTL should I set for Redis webhook signature keys?
Set SIGNATURE_TTL to 20–30% longer than your provider’s maximum redelivery window. Most spatial webhook providers retry for 24–72 hours, so a 90,000-second (25-hour) to 259,200-second (72-hour) TTL covers the redelivery gap without holding signatures longer than necessary. Anything shorter risks the key expiring while the provider is still retrying, which lets a duplicate slip through.
Does SET NX EX guarantee exactly-once processing under concurrent delivery?
Yes, for a single Redis node or a single Cluster slot. The SET ... NX EX operation is atomic in Redis — when N concurrent receivers race with the same signature, exactly one receives OK and the rest receive nil. The losers must discard their copy of the event. This is what makes the pattern safe even when several webhook receiver pods process the same retry storm simultaneously.
Should I fail open or fail closed when Redis is unavailable?
Fail open (proceed with processing) for most spatial telemetry pipelines, and rely on downstream PostGIS unique constraints to absorb the rare duplicate that gets through during a cache outage. Fail closed (reject the delivery) only when duplicate writes carry financial or regulatory weight — and in that case, queue the rejected payload for later reconciliation rather than dropping it.
Related
- Cache-Backed Idempotency Checks for Geospatial Webhooks — parent: the full pipeline from ingestion to conditional execution
- Generating Deterministic Idempotency Keys for GeoJSON Events — deep dive on BLAKE2b vs SHA-256 and recursive float normalization
- Idempotency & Spatial Deduplication — architectural overview of exactly-once semantics for geospatial event streams