Skip to main content

Webhook verification

verifyWebhookSignature checks the X-SF-Signature header of an incoming webhook and returns the parsed event.

A webhook created under Settings > Webhooks receives test deliveries only for now. To receive real geofence events at an endpoint, add a Webhook action to a workflow; its signing differs, see Webhook (HTTP Request). The signature, replay, and receiver guidance on this page applies to workspace webhook deliveries.

The secret is the webhook's signing secret. SpatialFlow shows it once, when you create the webhook, and again each time you rotate it. See Getting the signing secret.

verifyWebhookSignature​

function verifyWebhookSignature(options: VerifyWebhookOptions): WebhookEvent;

interface VerifyWebhookOptions {
payload: string | Buffer; // the raw request body
signature: string; // the X-SF-Signature header
secret: string; // the webhook signing secret
tolerance?: number; // deprecated and ignored
}

verifySignature is an alias for the same function.

The signature covers the raw bytes of the body. Pass the body exactly as it arrived. If a framework parses the JSON first and you re-serialize it, the signature will not match.

The function returns a WebhookEvent:

FieldTypeDescription
typestringEvent name, such as webhook.test
dataRecord<string, unknown>Event payload
created_atstring | undefinedEvent timestamp
idstring | undefinedEvent ID

The delivered body uses event and timestamp. The SDK copies them to type and created_at, and the original keys stay on the returned object at runtime. The type describes the default payload. A custom_payload_template replaces the whole body, so type and data are set only when the template renders event and data; the SDK doesn't check. The examples on this page assume the default payload.

Node HTTP example​

The receiver records each delivery in a processed_webhooks table, as described under Replay protection:

CREATE TABLE processed_webhooks (id text PRIMARY KEY);
CREATE TABLE geofence_visits (user_id text, geofence_id text);
import {createServer} from 'node:http';
import {Pool, type PoolClient} from 'pg';
import {verifyWebhookSignature, WebhookSignatureError, type WebhookEvent} from '@spatialflow/sdk';

const secret = process.env.WEBHOOK_SECRET!;
const pool = new Pool({connectionString: process.env.DATABASE_URL});
const MAX_BODY_BYTES = 1024 * 1024;

async function processEvent(client: PoolClient, event: WebhookEvent) {
if (event.type === 'geofence.enter') {
await client.query('INSERT INTO geofence_visits (user_id, geofence_id) VALUES ($1, $2)', [
event.data.user_id,
event.data.geofence_id,
]);
}
}

// Resolves false when the delivery was already processed
async function processOnce(id: string, event: WebhookEvent): Promise<boolean> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const inserted = await client.query(
'INSERT INTO processed_webhooks (id) VALUES ($1) ON CONFLICT (id) DO NOTHING RETURNING id',
[id],
);
if (inserted.rowCount === 0) {
await client.query('ROLLBACK');
return false;
}
await processEvent(client, event);
await client.query('COMMIT');
return true;
} catch (error) {
// Rolls back the id and the work, and surfaces as a 500
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}

createServer((req, res) => {
const chunks: Buffer[] = [];
let size = 0;
let tooLarge = false;
req.on('data', (chunk: Buffer) => {
if (tooLarge) return;
size += chunk.length;
if (size > MAX_BODY_BYTES) {
// Stop buffering before the signature is checked, so an unauthenticated
// sender cannot use up memory
tooLarge = true;
chunks.length = 0;
res.writeHead(413, {Connection: 'close'}).end(() => req.destroy());
return;
}
chunks.push(chunk);
});
req.on('end', async () => {
if (tooLarge) return;
try {
const event = verifyWebhookSignature({
payload: Buffer.concat(chunks),
signature: req.headers['x-sf-signature'] as string,
secret,
});

if (!event.id) {
// A custom_payload_template without an id leaves nothing to deduplicate on
res.writeHead(400).end('Missing delivery id');
return;
}

const processed = await processOnce(event.id, event);
res.writeHead(200).end(processed ? undefined : 'Already processed');
} catch (error) {
if (error instanceof WebhookSignatureError) {
res.writeHead(400).end(error.message);
return;
}
console.error(error);
res.writeHead(500).end();
}
});
}).listen(3000);

The body is limited to 1 MiB while it is read, and the server answers 413 for anything larger. Apply the same limit in any framework. With Express, mount express.raw({type: 'application/json', limit: '1mb'}) on the webhook route so req.body is a Buffer and oversized bodies get a 413, and pass it as payload.

Signature format​

The header is an HMAC-SHA256 of the raw request body, hex-encoded and prefixed with sha256=. The function also accepts a bare hex digest, and compares in constant time.

Replay protection​

The signature has no timestamp, so there is no time window to check, and an attacker holding a valid delivery can resend the same body and signature later. The X-Idempotency-Key and X-SF-Event-ID headers are not covered by the signature, so deduplicating on them does not stop a replay: the sender can change the header and keep the signed body.

After verifyWebhookSignature succeeds, deduplicate on the delivery id in the signed body (event.id). A retry of the same delivery carries the same id. The default payload carries a signed id, the delivery UUID. A custom_payload_template replaces the whole body with the rendered template, so the template must include a stable id for replay protection: "id": "{{ event.id }}". In a template, event is the default payload, and data, timestamp, and event_type are also available. If event.id is undefined, reject the delivery with a 400 instead of processing it without replay protection.

The example above does both: a verified body without an id gets a 400, and the id is recorded in the same transaction as the work.

Acknowledge a delivery only after its work is committed. SpatialFlow treats any 2xx response as success and does not resend it. Insert the id in the same database transaction as the work, with INSERT INTO processed_webhooks (id) VALUES ($1) ON CONFLICT (id) DO NOTHING RETURNING id. If no row comes back, the delivery is a duplicate, so return 200 without processing. Otherwise do the work in that transaction, and let any exception roll back both and return a non-2xx response so the retry processes the event. Do not catch a broad integrity error around the work, because a constraint failure inside it would look like a duplicate. A queue counts as accepting the work only if it has persisted the message before you respond. Redis does that only with appendfsync always. See the Webhooks concept page.

Errors​

WebhookSignatureError extends SpatialFlowError. It is thrown when:

  • The signature is missing or empty.
  • The signature does not match, because of a wrong secret or a changed body.
  • The payload is not valid JSON.
import {verifyWebhookSignature, WebhookSignatureError} from '@spatialflow/sdk';

try {
verifyWebhookSignature({payload: '{}', signature: 'sha256=00', secret: process.env.WEBHOOK_SECRET!});
} catch (error) {
if (error instanceof WebhookSignatureError) {
console.log(`Verification failed: ${error.message}`);
}
}

For the event names and payloads, see Webhooks.