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.

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.

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.