Triggering Map Refresh via Supabase Webhooks

Part of the Webhook-Triggered Updates guide.

Operative rule: Always validate the x-webhook-signature HMAC header before processing any Supabase webhook payload — skip this step and any HTTP client can corrupt your map state with fabricated row events.

How It Works

Supabase database webhooks operate outside the Realtime engine. When a row in a geospatial table receives an INSERT, UPDATE, or DELETE, Supabase serializes the change as a JSON payload and delivers it via HTTP POST to a developer-configured endpoint. Your backend validates the signature, extracts the minimum necessary fields (record ID, event type, timestamp), and pushes a lightweight update signal to connected frontend clients over WebSocket or Server-Sent Events. The frontend listener then fetches only the affected GeoJSON feature and patches the active map source in place — no full dataset reload required.

This decoupled approach sits naturally inside Data Refresh & Automation Pipelines: the database mutation triggers the delivery chain, while the frontend only renders what changed. Compared with Realtime channel subscriptions, webhooks give you explicit control over debouncing, payload trimming, and multi-table routing before any bytes reach the browser. When the same spatial data also drives Cache Invalidation Strategies, the webhook handler is the right place to emit a cache-bust signal alongside the client broadcast.

The webhook payload follows Supabase’s standard schema:

{
  "type": "UPDATE",
  "table": "geo_features",
  "record": { "id": "f8a2c1", "geom": "...", "properties": { "status": "active" } },
  "old_record": { "id": "f8a2c1", "geom": "...", "properties": { "status": "pending" } }
}

The record field carries the post-change state; old_record is available on UPDATE and DELETE. Strip the geometry from both before broadcasting — heavy coordinate arrays have no place in a client notification signal.

Supabase webhook to map refresh data flow Sequence diagram showing a database row change flowing through a Supabase webhook to a Node.js handler, then broadcasting over WebSocket to a MapLibre GL frontend which patches the map source. PostGIS table Supabase Node.js handler Browser / MapLibre INSERT / UPDATE / DELETE HMAC-signed POST verify sig · strip geom WS broadcast fetch /api/geo-features/:id source.setData() → re-render

Production-Ready Implementation

The Node.js/Express handler below demonstrates HMAC verification using express.raw() to preserve the exact byte sequence, table-scoped filtering, and WebSocket broadcast. Geometry is stripped before any bytes leave the server.

// server.js — Supabase webhook → WebSocket map-sync bridge
import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import crypto from 'crypto';

const app = express();
const SUPABASE_WEBHOOK_SECRET = process.env.SUPABASE_WEBHOOK_SECRET; // required

// Must use raw middleware — express.json() destroys the body bytes needed for HMAC
app.use(express.raw({ type: 'application/json', limit: '1mb' }));

function verifySignature(rawBody: Buffer, signature: string): boolean {
  const expected = crypto
    .createHmac('sha256', SUPABASE_WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');
  // Supabase prefixes the digest with "sha256="
  return `sha256=${expected}` === signature;
}

const server = createServer(app);
const wss = new WebSocketServer({ server });

app.post('/webhooks/map-sync', (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;

  if (!signature || !verifySignature(req.body, signature)) {
    return res.status(401).json({ error: 'Invalid webhook signature' });
  }

  let payload: { type: string; table: string; record: Record<string, unknown> };
  try {
    payload = JSON.parse(req.body.toString());
  } catch {
    return res.status(400).json({ error: 'Malformed JSON' });
  }

  const { type, table, record } = payload;

  // Ignore events from tables you don't need to sync
  if (table !== 'geo_features') return res.status(200).send('Ignored');

  // Strip geometry — only broadcast the minimum signal
  const updateSignal = {
    event: type,                                  // 'INSERT' | 'UPDATE' | 'DELETE'
    id: record.id,
    updated_at: record.updated_at ?? Date.now()
  };

  const message = JSON.stringify(updateSignal);
  wss.clients.forEach(client => {
    if (client.readyState === 1) client.send(message); // 1 = WebSocket.OPEN
  });

  res.status(200).json({ received: true, id: record.id });
});

server.listen(3000, () => console.log('Map sync server :3000'));

Register the webhook in the Supabase Dashboard under Database → Webhooks: set the HTTP URL to your handler endpoint, enable the INSERT, UPDATE, and DELETE events for geo_features, and copy the generated signing secret into SUPABASE_WEBHOOK_SECRET.

A database webhook fires per row, and geospatial edits rarely arrive one row at a time. A bulk import of ten thousand rows becomes ten thousand deliveries unless you put something between the trigger and the rebuild.

Absorbing a per-row webhook burst A debounce window schedules a rebuild a short time after the last event and cancels earlier timers, adding that window as latency. A coalescing queue key collapses many enqueued jobs into one pending job. A dirty-extent table records changed regions and a periodic worker drains it, which also gives you a precise purge list. 10 000 row updates should not mean 10 000 rebuilds debounce window schedule the rebuild T seconds after the last event; cancel the previous timer each time simplest · adds T of latency · a continuous stream can starve the rebuild forever without a max wait coalescing queue key enqueue under a fixed key; a second enqueue while one is pending is a no-op bounded latency · one rebuild in flight, at most one queued behind it — the usual right answer dirty-extent table the trigger records the changed bbox; a periodic worker unions the rows and rebuilds only that region most work to build · gives you an exact tile purge list for free · scales to any burst size

Alternative Variants

Server-Sent Events instead of WebSocket

If your infrastructure does not support long-lived WebSocket connections (some edge runtimes, serverless platforms), replace the WebSocketServer with an SSE response stream:

// SSE variant — replace wss.clients.forEach(...) with:
const sseClients = new Set<import('http').ServerResponse>();

app.get('/events/map-sync', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  sseClients.add(res);
  req.on('close', () => sseClients.delete(res));
});

// In the webhook handler, replace the wss.clients.forEach call with:
const data = `data: ${JSON.stringify(updateSignal)}\n\n`;
sseClients.forEach(client => client.write(data));

High-throughput: Redis pub/sub fan-out

When running multiple backend instances behind a load balancer, in-memory wss.clients only reaches clients connected to the same process. Route events through Redis instead:

Topology Pub/sub method Latency When to use
Single instance In-memory wss.clients ~1 ms Development, small deployments
Multiple instances Redis PUBLISH / SUBSCRIBE ~3–8 ms Horizontal scaling, HA setups
Message queue RabbitMQ or AWS SQS ~10–50 ms At-least-once delivery guarantee needed

For the Redis approach, publish updateSignal to a channel (e.g., geo:updates) in the webhook handler, then subscribe each backend instance’s WebSocket server to that channel and forward messages to local clients.

Which row events matter for a map

Not every database event should reach the map pipeline. Filtering at the trigger — by operation and by column — is much cheaper than filtering in a worker that has already been woken up.

Filtering row events at the trigger Inserts and deletes always change the map. An update touching the geometry column always changes it. An update touching a styled or labelled attribute changes it. An update touching only bookkeeping columns such as last_seen_at or updated_by never changes it and should be filtered out in the trigger condition. Filter in the trigger condition, not in the worker INSERT · DELETE always rebuild — the feature set itself changed UPDATE OF geom always rebuild — and purge the OLD tiles as well as the new UPDATE OF styled columns rebuild — anything that drives colour, size, filtering or a label UPDATE OF bookkeeping columns never — last_seen_at churn is the top source of pointless rebuilds

Verification Steps

After deploying the handler, confirm the integration is live:

  • Open your browser’s DevTools Network tab, filter by WS, and connect to your dashboard. Confirm the WebSocket handshake returns 101 Switching Protocols.
  • In the Supabase Dashboard, navigate to Database → Webhooks → [your hook] → Logs and confirm the most recent delivery shows HTTP 200 with {"received":true}.
  • Insert or update a row in geo_features directly via the Supabase SQL editor. Within 1–2 seconds the WebSocket client should receive a JSON message matching { event, id, updated_at }.
  • Verify the browser map updates the feature without a full page reload or visible flicker. In MapLibre GL, inspect the source with map.getSource('geo-features')._data in the console to confirm the feature’s properties changed.
  • Confirm curl -X POST https://your-domain.com/webhooks/map-sync -H "Content-Type: application/json" -d '{}' returns 401 — proving the signature check is enforced.

Common Errors & Fixes

401 Invalid webhook signature on every delivery

Root cause: The raw request body was consumed by a body-parser middleware before reaching the signature check. express.json() parses the stream, converting it to a JS object and losing the original byte sequence HMAC was computed over.

Fix: Replace express.json() with express.raw({ type: 'application/json' }) globally, or scope it to the webhook route only. Parse the buffer manually inside the handler with JSON.parse(req.body.toString()).

Map patches arrive out of order during rapid edits

Root cause: Two webhook deliveries arrive within milliseconds and the second fetch /api/geo-features/:id resolves before the first, causing the older state to overwrite the newer one.

Fix: Track a per-feature updated_at timestamp in the frontend. Before calling source.setData(), compare the incoming updated_at with the currently rendered feature’s timestamp and discard stale payloads.

WebSocket connection drops and map stops receiving updates

Root cause: Idle WebSocket connections are closed by load balancers or proxies after 60–120 seconds without traffic.

Fix: Implement client-side reconnection with exponential backoff. On reconnect, call a GET /api/geo-features/sync-check?since=<last_updated_at> endpoint to reconcile any features that changed during the disconnection window.

SyntaxError: Unexpected token when parsing the webhook body

Root cause: The raw body buffer was not converted to a string before passing to JSON.parse. Buffer objects stringify to [object Object], which is not valid JSON.

Fix: Use JSON.parse(req.body.toString()) or JSON.parse(req.body.toString('utf-8')) — never pass the raw Buffer directly.

Choosing What the Webhook Carries

A database webhook can send the changed row, a reference to it, or nothing but the fact that something changed, and the choice has consequences beyond convenience.

Sending the full row is simplest and works well for small records, but it makes the payload a copy of the data — which means it can be stale by the time it is processed, and it publishes every column to whatever receives it, including columns nobody intended to expose. Sending only the identity and letting the worker read the current row avoids both problems at the cost of a query, and it has the useful property that a burst of updates to one row results in the worker reading the final state once rather than replaying every intermediate version.

The middle option — identity plus the fields that determine whether a rebuild is needed — is often the practical compromise. It lets the endpoint decide quickly whether to enqueue anything at all without a database round trip, while still leaving the worker to read authoritative values.

Whatever the payload, it must carry enough for the receiver to be idempotent: a delivery identifier to deduplicate on, and a timestamp so an out-of-order delivery can be recognised rather than applied. Both are cheap and both turn a class of subtle bugs into conditions the endpoint can simply detect and drop.