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.

Webhook-triggered map update pipeline Six sequential stages: spatial database emits event, webhook endpoint receives and verifies, message queue buffers the payload, async worker transforms spatial data, cache and data store are refreshed, connected map clients are notified via WebSocket. Spatial DB PostGIS / IoT sensor / ETL POST Webhook endpoint HMAC verify enqueue Message queue Redis / SQS consume Async worker transform + validate GeoJSON write Cache + store CDN + PostGIS version bump notify Map clients MapLibre / Leaflet / Deck ① Emit ② Ingest ③ Buffer ④ Transform ⑤ Persist ⑥ Refresh
Webhook-triggered map update pipeline — six stages from spatial data change to live client refresh.

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, and version_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_id lookups, 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() before express.json() on the webhook route. Re-serialising the parsed body produces different bytes and breaks the HMAC.
  • crypto.timingSafeEqual prevents timing side-channel attacks by comparing digests in constant time.
  • The processedEvents Set above is illustrative. Replace it with Redis SETNX with 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.


A webhook endpoint has one job before it does anything useful: decide whether to believe the request. Four checks, in this order, separate a legitimate event from a replayed, forged or accidental one.

Four checks before a webhook is believed Verify the HMAC signature over the raw body with a constant-time comparison to reject forged payloads. Check the timestamp is within a few minutes to reject replays of captured requests. Record the delivery id and reject repeats to make duplicate deliveries harmless. Validate the payload against a schema so a malformed event fails fast instead of corrupting the pipeline. Admission control — in this order, before any work is queued 1 · HMAC signature over the RAW body, compared in constant time rejects forgery · re-serialising the JSON before hashing is the classic mistake — the bytes must be untouched 2 · timestamp within a short window rejects replay of a captured request · requires the signature to cover the timestamp too 3 · delivery id seen before? makes duplicate delivery harmless · providers retry on any non-2xx, including your own timeout 4 · validate the payload against a schema fails fast on an upstream change instead of writing malformed geometry into the pipeline

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.


The endpoint must answer in milliseconds and the rebuild takes minutes, so the two cannot be the same request. Every reliable webhook pipeline splits at exactly this point.

Acknowledge fast, rebuild slowly The synchronous side verifies the signature, deduplicates by delivery id, enqueues the job and returns 202 within about fifty milliseconds. The asynchronous side claims the job, transforms and tiles the data, publishes and purges over minutes, then notifies clients. Doing the rebuild inline causes provider timeouts and a retry storm. One request, two very different time budgets synchronous · target < 50 ms verify signature and timestamp deduplicate on the delivery id enqueue the job, return 202 no database writes, no file IO, no network calls asynchronous · minutes claim the job, fetch and transform tile, validate, publish, purge notify connected clients retries and failures are invisible to the provider Rebuild inline and the provider times out, retries, and you get N concurrent rebuilds of the same event.

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 before express.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 or ST_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.

Deciding Between a Webhook and a Schedule

Event-driven updates are not automatically better than scheduled ones; they are better for data whose changes are unpredictable and individually significant. A sensor reading, an incident report or a status change all justify a webhook because waiting for the next scheduled run would mean showing something known to be wrong.

Data that changes constantly but whose individual changes do not matter is the opposite case. A feed updating hundreds of rows a minute does not need hundreds of rebuilds; it needs one every few minutes, and a schedule expresses that more simply than a webhook plus a debounce. The useful test is whether a reader would act differently if they saw a change thirty seconds sooner. If not, a schedule is less machinery for the same result.

Many production pipelines end up with both, and that combination is worth designing rather than accumulating. The webhook path handles urgent changes; a scheduled run acts as a safety net that rebuilds from source regardless, catching anything the event path dropped — a delivery that failed after retries, an event that arrived while the worker was down, a change made directly in the database by a migration. The scheduled run is what keeps a missed event from becoming permanent, which is the failure mode a purely event-driven pipeline cannot detect on its own.