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.
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.
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.
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.
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.
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