Webhook-Triggered Map Updates for Geo-Dashboards
Part of the Data Refresh & Automation Pipelines guide.
Webhook-triggered updates are the most efficient mechanism for synchronizing spatial data across distributed geo-dashboards. Unlike polling architectures that waste compute cycles and introduce latency, event-driven payloads deliver changes precisely when underlying datasets mutate. For frontend and full-stack developers, GIS analysts, and agency teams building real-time mapping applications, this pattern eliminates unnecessary network overhead while guaranteeing that users always interact with current geographic features, sensor readings, or administrative boundaries.
When integrated into a broader Data Refresh & Automation Pipelines architecture, webhook-driven synchronization serves as the reactive layer that complements Scheduled Map Rebuild Workflows, batch processing, and streaming ingestion. This guide covers the pipeline architecture, production-ready code patterns, verification procedures, troubleshooting, and operational safeguards for implementing webhook-triggered map updates at scale.
Pipeline Architecture
The diagram below shows how a spatial data change travels from source database through ingestion, async processing, cache refresh, and finally client notification.
Prerequisites
Before deploying a webhook-driven mapping pipeline, verify the following infrastructure and development foundations are in place:
- HTTPS-Exposed Endpoint: Webhooks require a publicly routable, TLS-secured URL. Use a reverse proxy (
nginx,caddy, or a cloud load balancer) or a managed platform (Vercel, Cloudflare Workers, AWS API Gateway) to terminate SSL and route traffic. - Payload Schema Agreement: Coordinate with your data source (PostGIS triggers, FME pipelines, ArcGIS Online, IoT telemetry gateways) to standardize the JSON envelope. Include metadata fields:
event_id,event_type,geometry_bounds,timestamp, andversion_hash. - Mapping Framework Compatibility: Confirm your frontend library supports dynamic source updates without full page reloads. MapLibre GL JS, Leaflet, Deck.gl, and OpenLayers all expose methods for swapping GeoJSON or tile sources in-place. The Tile vs Vector Rendering Strategies you chose earlier determines whether you refresh a GeoJSON source or bust a tile URL.
- Idempotency Strategy: Webhook delivery is typically at-least-once. Your system must handle duplicate payloads gracefully using
event_idlookups, version tracking, or database upserts keyed on a stable spatial record identifier. - Security Baseline: Implement HMAC-SHA256 signature verification to reject forged requests. Store shared secrets in environment variables or a secrets manager — never in source control.
Step 1 — Receive and Verify the Payload
The webhook endpoint has one job: verify authenticity and return a 202 Accepted within 3–5 seconds. Per HTTP semantics, long-running processing must be offloaded immediately to prevent upstream timeout cascades and missed retry windows.
// Node.js / Express — production HMAC verification + idempotency guard
const crypto = require('crypto');
const express = require('express');
const app = express();
// Use raw body middleware so the HMAC is computed over the original bytes,
// not a re-serialised JSON object (a common source of signature mismatches).
app.use('/webhook', express.raw({ type: 'application/json' }));
function verifySignature(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const provided = Buffer.from(signatureHeader, 'hex');
const computed = Buffer.from(expected, 'hex');
if (provided.length !== computed.length) return false;
return crypto.timingSafeEqual(provided, computed);
}
app.post('/webhook/map-sync', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const secret = process.env.WEBHOOK_SECRET;
if (!verifySignature(req.body, signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(req.body);
const eventId = payload.event_id;
// Deduplicate — replace Set with Redis SETNX in production
if (processedEvents.has(eventId)) {
return res.status(200).json({ status: 'duplicate_ignored' });
}
processedEvents.add(eventId);
// Acknowledge immediately; heavy work goes to the queue
res.status(202).json({ status: 'queued' });
queueWorker.add('process-spatial-update', payload);
});
Key details:
- Use
express.raw()beforeexpress.json()on the webhook route. Re-serialising the parsed body produces different bytes and breaks the HMAC. crypto.timingSafeEqualprevents timing side-channel attacks by comparing digests in constant time.- The
processedEventsSet above is illustrative. Replace it withRedis SETNXwith a 24-hour TTL for multi-process deployments.
Step 2 — Dispatch to an Async Worker
Offload spatial transformation to a dedicated worker process. This decouples ingestion latency from processing time and allows independent scaling of each tier.
// BullMQ worker — spatial transformation job
const { Worker } = require('bullmq');
const { Pool } = require('pg');
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const worker = new Worker('spatial-updates', async (job) => {
const { event_type, feature_id, geometry_bounds, version_hash } = job.data;
// Fetch updated feature from PostGIS
const { rows } = await db.query(
`SELECT ST_AsGeoJSON(geom)::json AS geometry, properties
FROM spatial_features
WHERE id = $1`,
[feature_id]
);
if (!rows.length) throw new Error(`Feature ${feature_id} not found`);
const feature = {
type: 'Feature',
geometry: rows[0].geometry,
properties: { ...rows[0].properties, version_hash }
};
// Persist to cache (Redis) with version tag
await cacheClient.set(
`feature:${feature_id}`,
JSON.stringify(feature),
{ EX: 3600 }
);
// Notify connected WebSocket clients
pubsub.publish('map-updates', { event_type, feature_id, version_hash });
}, {
connection: { host: process.env.REDIS_HOST },
concurrency: 4,
});
Set concurrency based on your PostGIS connection pool size — a typical rule is concurrency ≤ pool_size / 2 to avoid contention during spike events.
Step 3 — Transform and Persist Spatial Data
The worker must validate geometry before writing, especially when the source emits delta payloads that may contain topology errors or incorrect coordinate order. CRS & Projection Management issues are the most common source of silent corruption at this stage — always confirm incoming coordinates match your storage CRS before upsert.
# Python / psycopg2 — upsert with topology validation
import json
import psycopg2
from shapely.geometry import shape
from shapely.validation import make_valid
def upsert_feature(conn, feature_id: str, geojson_geometry: dict, properties: dict, version_hash: str) -> None:
geom = shape(geojson_geometry)
# Repair self-intersections before writing
if not geom.is_valid:
geom = make_valid(geom)
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO spatial_features (id, geom, properties, version_hash, updated_at)
VALUES (%s, ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326), %s, %s, NOW())
ON CONFLICT (id) DO UPDATE
SET geom = EXCLUDED.geom,
properties = EXCLUDED.properties,
version_hash = EXCLUDED.version_hash,
updated_at = EXCLUDED.updated_at
""",
(feature_id, json.dumps(geom.__geo_interface__), json.dumps(properties), version_hash)
)
conn.commit()
After the upsert, increment the dataset’s version counter and record it alongside the cache-control metadata used by Cache Invalidation Strategies to bust stale tile responses downstream.
Step 4 — Notify Connected Clients
Once the cache and data store are updated, broadcast a lightweight signal. Map clients react by fetching the updated source — they do not receive the full payload over the WebSocket channel, which keeps message sizes small and prevents head-of-line blocking on slow connections.
// Server — broadcast version token via WebSocket (ws library)
function broadcastMapUpdate(wss, featureId, versionHash) {
const message = JSON.stringify({
type: 'map-update',
feature_id: featureId,
version_hash: versionHash,
});
wss.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
client.send(message);
}
});
}
// Client — MapLibre GL JS dynamic source refresh
function onMapUpdateMessage(map, sourceId, event) {
const { version_hash } = JSON.parse(event.data);
const source = map.getSource(sourceId);
if (source) {
// Cache-bust the GeoJSON endpoint with the new version token
source.setData(`/api/features.geojson?v=${version_hash}`);
}
}
For tile-based architectures, append the version token as a query parameter on the tile URL template (/tiles/{z}/{x}/{y}.mvt?v={version_hash}) rather than calling clearTiles(), which flashes the entire layer. This pairs directly with the Tile vs Vector Rendering Strategies you selected during initial architecture — MVT sources and GeoJSON sources require different bust mechanisms.
Manage your CDN TTLs so they align with your event frequency. Overly aggressive caching masks webhook updates; zero-TTL configurations spike origin load. Apply the guidance in Cache Invalidation Strategies to find the right balance for your update cadence.
For PostGIS-backed applications that want to bypass custom ETL layers entirely, triggering map refresh via Supabase webhooks shows how Supabase database change notifications can be routed directly to the notification step.
Step 5 — Verification & Smoke-Test
Confirm end-to-end delivery before accepting production traffic.
1. Trigger a test event via cURL:
PAYLOAD='{"event_id":"test-001","event_type":"feature_updated","feature_id":"parcel-42","version_hash":"abc123"}'
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -X POST https://your-domain.com/webhook/map-sync \
-H "Content-Type: application/json" \
-H "x-webhook-signature: $SIG" \
-d "$PAYLOAD"
# Expected: {"status":"queued"}
2. Inspect the queue:
# BullMQ — list active and completed jobs
npx bull-board # or query via bullmq dashboard
# Redis CLI — check idempotency key
redis-cli GET "event:test-001"
3. Verify the database write:
SELECT id, version_hash, updated_at
FROM spatial_features
WHERE id = 'parcel-42';
-- version_hash should equal 'abc123'
4. Confirm the client update in browser DevTools:
Open the Network tab, filter by WebSocket frames. After the worker completes, you should see a map-update frame arrive, followed by a GeoJSON or tile request containing ?v=abc123.
Troubleshooting
Why does my webhook return 200 but the map never updates?
The handler is performing the spatial transformation synchronously before returning, then timing out before the client notification fires. Move all processing after the initial 202 Accepted response into the async worker. Confirm the queue is being consumed by checking worker process logs and inspecting the queue depth.
How do I handle duplicate webhook deliveries?
Replace the in-process Set in the example above with Redis SETNX keyed on event_id with a 24-hour TTL. On receipt, attempt SETNX event:<id> 1 EX 86400; a return value of 0 means a duplicate — return 200 { status: 'duplicate_ignored' } immediately without re-queuing.
What causes HMAC signature mismatches?
The most common root cause is body-parsing middleware consuming the raw buffer before your verification function reads it. Ensure you use express.raw({ type: 'application/json' }) (not express.json()) on the webhook route. Compute the HMAC over req.body before calling JSON.parse(). Also check that the sending service uses the same encoding (UTF-8 bytes, not a stringified object) when computing its own signature.
How do I prevent tile cache from serving stale data after a webhook fires?
Append a content hash or incrementing version number as a query parameter to tile URL templates after each write. Set Cache-Control: public, max-age=86400, stale-while-revalidate=60 on the tile endpoint and include the version in the CDN cache key. This avoids a full CDN purge while still ensuring clients see fresh tiles after the version token changes. Detailed strategies are covered in Cache Invalidation Strategies.
Can one webhook endpoint serve multiple geo-dashboard tenants?
Yes. Include a dashboard_id or tenant_id field in your payload schema. After verification, the worker reads this field and routes the update to the correct spatial dataset and WebSocket channel. Maintain separate idempotency namespaces per tenant: event:<tenant_id>:<event_id>.
Gotchas & Edge Cases
- Body parser order matters. Registering
express.json()globally beforeexpress.raw()on the webhook route means the raw buffer is already consumed. Mount the raw middleware first, scoped to the webhook path. - At-least-once delivery is the default for every major webhook provider. GitHub, Stripe, Supabase, and most IoT gateways all retry on network failure. Design for idempotency from day one, not as an afterthought.
- Topology errors in delta payloads. A partial geometry update (e.g., a single moved vertex in a complex polygon) can introduce self-intersections that PostGIS rejects on insert. Run
make_valid()from Shapely orST_MakeValid()in PostGIS before every upsert. - WebSocket fan-out at scale. A single Node.js process handles ~10k concurrent WebSocket connections before memory pressure becomes a concern. At higher concurrency, use a Pub/Sub broker (Redis Pub/Sub, NATS) so multiple server processes share the notification channel.
- Missed events during deployment restarts. If your webhook endpoint is briefly unavailable during a rolling deploy, the sending service will retry — but only up to its configured retry window. Configure Scheduled Map Rebuild Workflows as a nightly reconciliation baseline to catch any spatial drift caused by missed events.
- Spatial drift from dropped events. Network partitions can cause events to expire before they are retried. A full-state reconciliation job run on a fixed schedule catches these gaps and guarantees data consistency across all distributed caches and edge nodes.
- Coordinate axis order. Some IoT gateways and legacy GIS systems emit coordinates as
[lat, lon]rather than GeoJSON’s[lon, lat]. Validate axis order at the ingestion boundary — after HMAC verification but before queuing — so axis-swapped payloads never reach the database layer.
Related
- Data Refresh & Automation Pipelines — parent guide covering the full refresh architecture
- Triggering Map Refresh via Supabase Webhooks — PostGIS-native change notifications without custom ETL
- Cache Invalidation Strategies — CDN TTL management and tile cache busting after live updates
- Scheduled Map Rebuild Workflows — nightly full-state reconciliation to complement event-driven updates
- Incremental Data Processing — delta-based ingestion patterns that feed the webhook transformation stage
- Tile vs Vector Rendering Strategies — choose the right client-side source type before wiring up the notification step