Skip to main content

Webhooks

Overview

Webhooks enable real-time notifications when geofencing events occur in SpatialFlow. When a device enters or exits a geofence, SpatialFlow can automatically send HTTP requests to your specified endpoint, allowing you to trigger custom workflows, update external systems, or send notifications through your own infrastructure.

What are Webhooks?

Webhooks are HTTP callbacks that SpatialFlow sends to your server when specific events occur. Rather than continuously polling our API for updates, webhooks provide a push-based mechanism where SpatialFlow proactively notifies your application of important events.

Common Use Cases

  • Real-time alerts: Send notifications to your own systems when vehicles arrive at customer locations
  • Integration triggers: Update CRM records, ticketing systems, or databases when geofence events occur
  • Custom workflows: Trigger complex business logic in your own applications based on location events
  • Audit logging: Maintain your own record of all geofencing activity for compliance

Webhook Payload

Every webhook delivery includes a JSON payload with standardized event information.

Request Structure

SpatialFlow makes HTTP POST requests to your webhook endpoint with the following characteristics:

  • Method: POST (configurable to PUT, PATCH, or GET)
  • Content-Type: application/json (default, configurable)
  • Timeout: 30 seconds (default, configurable up to 300s)
  • User-Agent: SpatialFlow-Webhooks/1.0

Headers

Each webhook request includes security and metadata headers:

HeaderDescriptionExample
X-SF-SignatureHMAC-SHA256 signature for verificationsha256=a1b2c3d4...
X-SF-Event-IDUnique identifier for the eventgf_abc123_enter_1729520000
X-Idempotency-KeyUUID for idempotent delivery handling550e8400-e29b-41d4-a716-446655440000
X-SF-AttemptCurrent attempt number (1-8)1
Content-TypeRequest content typeapplication/json
User-AgentSpatialFlow identifierSpatialFlow-Webhooks/1.0

Custom Headers

You can add custom headers to webhook requests when creating or updating a webhook:

{
"name": "My Webhook",
"url": "https://example.com/webhook",
"headers": {
"X-Custom-API-Key": "your-secret-key",
"X-Tenant-ID": "tenant-123"
}
}

Payload Format

The JSON body includes comprehensive event information:

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"event": "geofence.enter",
"timestamp": "2025-10-21T14:12:03Z",
"data": {
"event_id": "gf_abc123_enter_1729520000",
"user_id": "user-789",
"geofence_id": "770fa600-g4ad-63f6-c938-668877662222",
"geofence_name": "Warehouse A",
"event_type": "enter",
"point": {
"type": "Point",
"coordinates": [-73.756, 42.651]
},
"metadata": {
"geofence_metadata": {
"zone_type": "delivery",
"priority": "high"
},
"event_metadata": {
"device_type": "mobile",
"app_version": "2.1.0"
}
}
}
}

Event Types

  • geofence.enter - Device entered a geofence
  • geofence.exit - Device exited a geofence
  • webhook.test - Test event from webhook configuration

Webhook Security

Security is critical for webhook delivery. SpatialFlow implements multiple security measures to ensure authenticity and prevent tampering.

Request Signing

Every webhook request includes an HMAC-SHA256 signature in the X-SF-Signature header. This signature proves the request came from SpatialFlow and hasn't been modified in transit.

How Signatures Work

  1. SpatialFlow generates a unique secret when you create a webhook
  2. For each delivery, we compute an HMAC-SHA256 hash of the raw JSON payload using your secret
  3. The signature is sent in the X-SF-Signature header as sha256=<hex_digest>
  4. Your endpoint should verify this signature before processing the webhook

Verification Examples

Python

import hmac
import hashlib

def verify_webhook_signature(request_body: bytes, signature_header: str, secret: str) -> bool:
"""
Verify webhook signature.

Args:
request_body: Raw request body bytes (don't parse JSON first!)
signature_header: Value of X-SF-Signature header
secret: Your webhook secret from SpatialFlow

Returns:
True if signature is valid, False otherwise
"""
if not signature_header.startswith("sha256="):
return False

received_signature = signature_header[7:] # Remove "sha256=" prefix

# Compute expected signature
computed_signature = hmac.new(
secret.encode('utf-8'),
request_body,
hashlib.sha256
).hexdigest()

# Constant-time comparison to prevent timing attacks
return hmac.compare_digest(computed_signature, received_signature)

# Usage with Flask
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-SF-Signature')
secret = "your-webhook-secret" # From SpatialFlow webhook config

if not verify_webhook_signature(request.data, signature, secret):
return {"error": "Invalid signature"}, 401

# Process webhook payload
payload = request.json
print(f"Received event: {payload['event']}")

return {"success": True}, 200

Node.js

const crypto = require('crypto');
const express = require('express');

function verifyWebhookSignature(requestBody, signatureHeader, secret) {
/**
* Verify webhook signature.
*
* @param {string|Buffer} requestBody - Raw request body
* @param {string} signatureHeader - Value of X-SF-Signature header
* @param {string} secret - Your webhook secret from SpatialFlow
* @returns {boolean} True if signature is valid
*/
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) {
return false;
}

const receivedSignature = signatureHeader.substring(7); // Remove "sha256=" prefix

// Compute expected signature
const hmac = crypto.createHmac('sha256', secret);
hmac.update(requestBody);
const computedSignature = hmac.digest('hex');

// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(computedSignature)
);
}

// Usage with Express
const app = express();

app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-sf-signature'];
const secret = process.env.WEBHOOK_SECRET; // From SpatialFlow

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

// Process webhook payload
const payload = JSON.parse(req.body);
console.log(`Received event: ${payload.event}`);

res.json({success: true});
});

app.listen(3000, () => console.log('Webhook server running on port 3000'));
Important

Always verify the signature before processing webhook data. Never trust webhook requests without verification, as malicious actors could attempt to spoof requests to your endpoint.

Header Naming

SpatialFlow sends outbound webhook deliveries with the X-SF-Signature header. If you are using SpatialFlow's inbound webhook receiver (POST /api/v1/webhooks/receive/{webhook_id}, for third-party webhooks sent to SpatialFlow), the signature is read from the X-Webhook-Signature header (with an X-Webhook-Timestamp companion). These are separate systems — most integrations only need X-SF-Signature for verifying deliveries from SpatialFlow.

Idempotency

The X-Idempotency-Key header contains a unique UUID for each delivery attempt. Use this key to prevent duplicate processing if SpatialFlow retries a delivery.

# Example: Track processed deliveries
processed_deliveries = set() # In production, use Redis or database

def handle_webhook(request):
idempotency_key = request.headers.get('X-Idempotency-Key')

# Check if already processed
if idempotency_key in processed_deliveries:
return {"status": "already_processed"}, 200

# Verify signature
if not verify_webhook_signature(request.body, request.headers.get('X-SF-Signature'), secret):
return {"error": "Invalid signature"}, 401

# Process webhook
payload = request.json()
process_event(payload)

# Mark as processed
processed_deliveries.add(idempotency_key)

return {"success": True}, 200

Delivery Guarantees

SpatialFlow implements robust delivery guarantees to ensure your webhooks are delivered reliably.

Retry Logic

If a webhook delivery fails, SpatialFlow automatically retries with exponential backoff:

AttemptDelay After FailureTotal Time Elapsed
115 minutes0s
230 minutes15m
31 hour45m
44 hours1h 45m
54 hours5h 45m
64 hours9h 45m
74 hours13h 45m
8(final attempt)17h 45m

The delivery is tried once, then retried up to 7 times (8 attempts total) on the fixed schedule [15m, 30m, 1h, 4h, 4h, 4h, 4h] (±10% jitter per retry).

Total retry window: Approximately 17 hours 45 minutes from first failure to final attempt.

Success Criteria

A webhook delivery is considered successful if:

  • HTTP response status code is 2xx (200-299)
  • Response received within timeout period (default 30 seconds)

Failure Conditions

A delivery is retried if:

  • HTTP status code is not 2xx (200-299)
  • Connection timeout (exceeds configured timeout)
  • Connection error (DNS failure, network error, refused connection, etc.)

Dead Letter Queue (DLQ)

When a webhook fails all 8 delivery attempts (the initial try plus 7 retries), it's moved to the Dead Letter Queue (DLQ). The DLQ provides:

  • Visibility into permanent failures
  • Manual retry capability via API
  • Error debugging information with full error details

Viewing Failed Deliveries

GET /api/v1/webhooks/dlq?limit=50&offset=0&requeued=false

Response includes failed deliveries with error details:

{
"results": [
{
"dlq_id": "550e8400-e29b-41d4-a716-446655440000",
"delivery_id": "660e9500-f39c-52e5-b827-557766551111",
"webhook": {
"id": "770fa600-g4ad-63f6-c938-668877662222",
"name": "Production Alerts",
"url": "https://example.com/webhook"
},
"event_type": "geofence.enter",
"event_id": "gf_abc123_enter_1729520000",
"error_message": "HTTP 500: Internal Server Error",
"retry_count": 7,
"last_attempt_at": "2025-10-21T14:30:00Z",
"queued_at": "2025-10-21T14:30:00Z",
"requeued": false
}
],
"pagination": {
"total": 12,
"limit": 50,
"offset": 0,
"has_more": false
}
}

Retrying from DLQ

After fixing the underlying issue (e.g., updating webhook URL or fixing your endpoint), retry failed deliveries. Because a DLQ'd delivery has already used all of its attempts, you must pass {"force": true} in the body to re-run it:

POST /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retry
Content-Type: application/json

{"force": true}

This creates a new delivery attempt with a fresh retry counter. (Alternatively, replay by DLQ entry with POST /api/v1/webhooks/dlq/{dlq_id}/retry, which resets the counter and needs no body.)

Creating Webhook Endpoints

Requirements

Your webhook endpoint must:

  • Respond quickly: Return a response within the configured timeout (default 30s)
  • Return 2xx status: Any status code in the 200-299 range indicates success
  • Handle retries: Process the same event multiple times safely (use X-Idempotency-Key)
  • Verify signatures: Always verify the X-SF-Signature header before processing

Best Practices

1. Respond Immediately

Process webhooks asynchronously. Acknowledge receipt immediately, then queue the actual work:

from flask import Flask, request
import queue

app = Flask(__name__)
work_queue = queue.Queue()

@app.route('/webhook', methods=['POST'])
def webhook():
# Verify signature
if not verify_signature(request.data, request.headers.get('X-SF-Signature')):
return {"error": "Invalid signature"}, 401

# Queue for async processing
work_queue.put(request.json)

# Respond immediately
return {"success": True}, 200

# Separate worker processes the queue
def process_webhook_queue():
while True:
payload = work_queue.get()
# Do expensive processing here
process_event(payload)

2. Implement Idempotency

Track processed deliveries to prevent duplicate processing:

from redis import Redis

redis = Redis()

def handle_webhook(request):
idempotency_key = request.headers.get('X-Idempotency-Key')

# Check Redis for processed key
if redis.exists(f"webhook:processed:{idempotency_key}"):
return {"status": "already_processed"}, 200

# Process webhook
process_event(request.json)

# Mark as processed (expire after 7 days)
redis.setex(f"webhook:processed:{idempotency_key}", 604800, "1")

return {"success": True}, 200

3. Handle Errors Gracefully

Log errors and return appropriate status codes:

@app.route('/webhook', methods=['POST'])
def webhook():
try:
# Verify signature
if not verify_signature(request.data, request.headers.get('X-SF-Signature')):
return {"error": "Invalid signature"}, 401

# Process webhook
result = process_event(request.json)

return {"success": True, "result": result}, 200

except ValidationError as e:
# Validation errors should not be retried
logger.error(f"Validation error: {e}")
return {"error": "Invalid payload"}, 400

except Exception as e:
# Unexpected errors trigger retries
logger.error(f"Webhook processing error: {e}")
return {"error": "Internal server error"}, 500

4. Monitor and Alert

Set up monitoring for webhook delivery failures:

import logging
from prometheus_client import Counter

webhook_received = Counter('webhook_received_total', 'Total webhooks received')
webhook_processed = Counter('webhook_processed_total', 'Total webhooks processed successfully')
webhook_failed = Counter('webhook_failed_total', 'Total webhook processing failures')

@app.route('/webhook', methods=['POST'])
def webhook():
webhook_received.inc()

try:
process_event(request.json)
webhook_processed.inc()
return {"success": True}, 200
except Exception as e:
webhook_failed.inc()
logger.error(f"Webhook failed: {e}")
return {"error": str(e)}, 500

Example Implementation

Complete webhook receiver with all best practices:

import hmac
import hashlib
import logging
from flask import Flask, request
from redis import Redis

app = Flask(__name__)
redis = Redis()
logger = logging.getLogger(__name__)

WEBHOOK_SECRET = "your-webhook-secret"

def verify_signature(body: bytes, signature: str) -> bool:
"""Verify HMAC signature"""
if not signature or not signature.startswith("sha256="):
return False

received = signature[7:]
computed = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
body,
hashlib.sha256
).hexdigest()

return hmac.compare_digest(computed, received)

@app.route('/webhook', methods=['POST'])
def webhook():
"""Handle SpatialFlow webhook"""

# 1. Verify signature
signature = request.headers.get('X-SF-Signature')
if not verify_signature(request.data, signature):
logger.warning("Invalid webhook signature")
return {"error": "Invalid signature"}, 401

# 2. Check idempotency
idempotency_key = request.headers.get('X-Idempotency-Key')
if redis.exists(f"webhook:processed:{idempotency_key}"):
logger.info(f"Duplicate webhook: {idempotency_key}")
return {"status": "already_processed"}, 200

# 3. Parse payload
try:
payload = request.json
event_type = payload.get('event')
event_data = payload.get('data', {})

logger.info(f"Received webhook: {event_type}")

# 4. Queue for async processing
redis.rpush('webhook:queue', request.data)

# 5. Mark as processed
redis.setex(f"webhook:processed:{idempotency_key}", 604800, "1")

# 6. Respond immediately
return {"success": True}, 200

except Exception as e:
logger.error(f"Webhook error: {e}")
return {"error": "Internal server error"}, 500

if __name__ == '__main__':
app.run(port=3000)

Testing Webhooks

Using Test Events

Trigger a test webhook delivery from the SpatialFlow dashboard or API:

POST /api/v1/webhooks/{webhook_id}/test

This sends a test event with the webhook.test event type.

Local Development

For local testing, use tools like ngrok to expose your local server:

# Start your webhook server locally
python webhook_server.py

# In another terminal, expose it
ngrok http 3000

# Use the ngrok URL in SpatialFlow
# https://abc123.ngrok.io/webhook

Debugging Tips

  1. Check signature verification: Ensure you're using the correct secret
  2. Verify raw body: Use the raw request body for signature verification, not parsed JSON
  3. Test with curl: Simulate webhook requests manually
  4. Check logs: Review webhook delivery logs in SpatialFlow dashboard
  5. Monitor DLQ: Check the Dead Letter Queue for failed deliveries

Monitoring & Debugging

Webhook Delivery Logs

View delivery history and status for each webhook:

GET /api/v1/webhooks/{webhook_id}/deliveries?limit=50

Response includes delivery attempts with timing and status:

{
"results": [
{
"id": "delivery-123",
"status": "success",
"event_type": "geofence.enter",
"response_status_code": 200,
"response_time_ms": 145.3,
"attempt_count": 1,
"created_at": "2025-10-21T14:12:03Z",
"delivered_at": "2025-10-21T14:12:03Z",
"next_retry_at": null
}
]
}

Common Issues

IssueCauseSolution
401 UnauthorizedSignature verification failedCheck webhook secret, verify you're using raw request body
TimeoutResponse took longer than configured timeoutImplement async processing, return 200 immediately
Connection refusedWebhook URL unreachableVerify URL is correct and server is running
SSL errorsCertificate issuesEnsure HTTPS endpoint has valid SSL certificate
500 errorsApplication error in your endpointCheck application logs, fix bugs in webhook handler

DLQ Statistics

Monitor Dead Letter Queue depth:

GET /api/v1/webhooks/dlq/stats
{
"total_entries": 12,
"not_requeued": 8,
"requeued": 4,
"top_failed_webhooks": [
{
"webhook_id": "770fa600-g4ad-63f6-c938-668877662222",
"webhook_name": "Production Alerts",
"count": 5
}
]
}

Set up alerts when DLQ depth exceeds thresholds.

Rate Limits

Each webhook stores a configurable per-minute rate-limit value:

  • Default: 60 requests per minute per webhook
  • Configurable: Set a custom rate_limit_per_minute when creating webhooks
note

This value is stored and surfaced on the webhook, but per-webhook delivery is not currently throttled against it at send time — deliveries are dispatched as events occur. Treat rate_limit_per_minute as configuration for a future enforcement pass, not a live guarantee. Design your receiver to absorb bursts.

Configure the value when creating a webhook:

{
"name": "My Webhook",
"url": "https://example.com/webhook",
"rate_limit_per_minute": 120
}

Next Steps