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.
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 returns101 Switching Protocols. - In the Supabase Dashboard, navigate to Database → Webhooks → [your hook] → Logs and confirm the most recent delivery shows HTTP
200with{"received":true}. - Insert or update a row in
geo_featuresdirectly 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')._datain 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 '{}'returns401— 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.
Related
- Webhook-Triggered Updates — parent guide covering the full event-driven refresh architecture
- Cache Invalidation Strategies — emit cache-bust signals from the same webhook handler
- Scheduled Map Rebuild Workflows — complement webhooks with time-based rebuilds for batch ingestion pipelines
- Incremental Data Processing — process only changed records upstream before the webhook fires