Webhook Integration Guide
Learn how to integrate SpatialFlow webhooks with popular services and build custom webhook receivers for real-time location event notifications.
Overview
Webhooks allow SpatialFlow to send real-time HTTP requests to your application when geofence events occur. This guide covers:
- Integrating with popular services (Slack, Discord, custom apps)
- Building custom webhook receivers
- Testing webhooks locally
- Production best practices
- Security and authentication
Quick Start
1. Create a Webhook in a Workflow
curl -X POST https://api.spatialflow.io/api/v1/workflows \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Geofence Entry Notification",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Geofence Entry",
"config": {
"geofence_ids": ["your-geofence-id"]
}
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "HTTP Request",
"config": {
"url": "https://your-app.com/webhooks/geofence-entry",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"body": {
"event_type": "{{trigger.event_type}}",
"timestamp": "{{trigger.timestamp}}",
"geofence_id": "{{trigger.geofence_id}}",
"geofence_name": "{{trigger.geofence_name}}",
"device_id": "{{trigger.device_id}}",
"device_name": "{{trigger.device_name}}",
"location": {
"latitude": "{{trigger.location.latitude}}",
"longitude": "{{trigger.location.longitude}}"
}
}
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"}
]
}'
2. Receive Webhook Events
SpatialFlow will POST the body you defined in the action config, with template variables resolved. Using the body template above, you would receive:
{
"event_type": "entry",
"timestamp": "2025-11-10T12:00:00Z",
"geofence_id": "c3d4e5f6-a7b8-9012-cdef-234567890123",
"geofence_name": "Downtown Office",
"device_id": "dev_mobile123",
"device_name": "John's Phone",
"location": {
"latitude": 37.7749,
"longitude": -122.4194
}
}
Integrating with Popular Services
Slack Integration
Send geofence events to Slack channels using Incoming Webhooks.
Step 1: Create Slack Incoming Webhook
- Go to Slack API
- Click "Create New App" → "From scratch"
- Enable "Incoming Webhooks"
- Click "Add New Webhook to Workspace"
- Select channel and authorize
- Copy the webhook URL
Step 2: Create SpatialFlow Workflow
curl -X POST https://api.spatialflow.io/api/v1/workflows \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Notify Slack on Entry",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Geofence Entry",
"config": { "geofence_ids": ["your-geofence-id"] }
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "slack",
"label": "Slack Notification",
"config": {
"webhook_url": "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK",
"message": "🚗 {{trigger.device_name}} entered {{trigger.geofence_name}}",
"channel": "#operations"
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"}
]
}'
Example Slack Message:
🚗 John's Phone entered Downtown Office
Location: 37.7749, -122.4194
Time: 2025-11-10 12:00 PM
Discord Integration
Send notifications to Discord channels using webhooks.
Step 1: Create Discord Webhook
- Open Discord server settings
- Go to Integrations → Webhooks
- Click "New Webhook"
- Name it and select channel
- Copy webhook URL
Step 2: Create SpatialFlow Workflow
curl -X POST https://api.spatialflow.io/api/v1/workflows \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Notify Discord on Entry",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Geofence Entry",
"config": { "geofence_ids": ["your-geofence-id"] }
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "Discord Message",
"config": {
"webhook_url": "https://discord.com/api/webhooks/YOUR/WEBHOOK",
"content": "**Device Alert**: {{trigger.device_name}} entered {{trigger.geofence_name}} at {{trigger.location.latitude}}, {{trigger.location.longitude}}"
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"}
]
}'
Microsoft Teams Integration
Send notifications to Teams channels.
Step 1: Create Teams Incoming Webhook
- Open Teams channel → "..." → Connectors
- Search for "Incoming Webhook"
- Configure and create
- Copy webhook URL
Step 2: Create SpatialFlow Workflow
curl -X POST https://api.spatialflow.io/api/v1/workflows \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Notify Teams on Entry",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Geofence Entry",
"config": { "geofence_ids": ["your-geofence-id"] }
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "Teams Notification",
"config": {
"webhook_url": "https://outlook.office.com/webhook/YOUR/WEBHOOK",
"title": "Geofence Entry Alert",
"text": "{{trigger.device_name}} entered {{trigger.geofence_name}}"
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"}
]
}'
Building a Custom Webhook Receiver
Flask (Python) Example
from flask import Flask, request, jsonify
import hmac
import hashlib
app = Flask(__name__)
# Your webhook secret from SpatialFlow
WEBHOOK_SECRET = "your_webhook_secret_here"
def verify_signature(payload, signature):
"""Verify HMAC signature from SpatialFlow."""
expected_signature = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
provided_signature = signature.replace('sha256=', '')
return hmac.compare_digest(expected_signature, provided_signature)
@app.route('/webhooks/geofence-events', methods=['POST'])
def handle_geofence_event():
"""Handle incoming geofence event webhooks."""
# Get raw request body
payload = request.get_data()
# Verify signature
signature = request.headers.get('X-SF-Signature')
if not signature or not verify_signature(payload, signature):
return jsonify({"error": "Invalid signature"}), 401
# Parse event data
event = request.get_json()
# Process different event types
event_type = event.get('event_type')
if event_type == 'entry':
device_name = event['device_name']
geofence_name = event['geofence_name']
print(f"🚗 {device_name} entered {geofence_name}")
# Your custom logic here
# - Send email notification
# - Update database
# - Trigger other workflows
elif event_type == 'exit':
device_name = event['device_name']
geofence_name = event['geofence_name']
print(f"🚪 {device_name} exited {geofence_name}")
# Return success (2xx status code stops retries)
return jsonify({"status": "success"}), 200
if __name__ == '__main__':
app.run(port=5000)
Express (Node.js) Example
const express = require('express');
const crypto = require('crypto');
const bodyParser = require('body-parser');
const app = express();
// Your webhook secret from SpatialFlow
const WEBHOOK_SECRET = 'your_webhook_secret_here';
// Use raw body parser for signature verification
app.use(bodyParser.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
function verifySignature(payload, signature) {
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
const providedSignature = signature.replace('sha256=', '');
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(providedSignature)
);
}
app.post('/webhooks/geofence-events', (req, res) => {
// Verify signature
const signature = req.headers['x-sf-signature'];
if (!signature || !verifySignature(req.rawBody, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = req.body;
const eventType = event.event_type;
// Process different event types
if (eventType === 'entry') {
console.log(`🚗 ${event.device_name} entered ${event.geofence_name}`);
// Your custom logic here
// - Send notification
// - Update database
// - Trigger actions
} else if (eventType === 'exit') {
console.log(`🚪 ${event.device_name} exited ${event.geofence_name}`);
}
// Return success (2xx stops retries)
res.json({ status: 'success' });
});
app.listen(5000, () => {
console.log('Webhook receiver listening on port 5000');
});
Idempotency Handling
Always handle duplicate webhook deliveries:
# Store processed event IDs in database or cache
processed_events = set()
@app.route('/webhooks/geofence-events', methods=['POST'])
def handle_webhook():
event = request.get_json()
event_id = event['event_id']
# Check if already processed
if event_id in processed_events:
print(f"Duplicate event {event_id}, skipping")
return jsonify({"status": "already_processed"}), 200
# Process event
process_event(event)
# Mark as processed
processed_events.add(event_id)
return jsonify({"status": "success"}), 200
Testing Webhooks Locally
Option 1: webhook.site (Quick Testing)
- Go to webhook.site
- Copy your unique URL
- Use it in your workflow webhook action
- View incoming requests in real-time
Example:
curl -X POST https://api.spatialflow.io/api/v1/workflows \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Test Webhook",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Geofence Entry",
"config": { "geofence_ids": ["test-geofence"] }
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "HTTP Request",
"config": {
"url": "https://webhook.site/your-unique-id",
"method": "POST"
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"}
]
}'
Option 2: ngrok (Local Development)
Expose your local server to the internet for testing:
Step 1: Install ngrok
# Mac
brew install ngrok
# Or download from https://ngrok.com/download
Step 2: Start your local server
python app.py # Runs on localhost:5000
Step 3: Create tunnel
ngrok http 5000
Step 4: Use ngrok URL in workflow
# ngrok provides: https://abc123.ngrok.io
curl -X POST https://api.spatialflow.io/api/v1/workflows \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Local Dev Webhook",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Geofence Entry",
"config": { "geofence_ids": ["your-geofence-id"] }
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "HTTP Request",
"config": {
"url": "https://abc123.ngrok.io/webhooks/geofence-events",
"method": "POST"
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"}
]
}'
Production Best Practices
1. Always Verify Signatures
Never skip signature verification in production:
# ❌ BAD: No signature verification
@app.route('/webhook', methods=['POST'])
def handle_webhook():
event = request.get_json()
process_event(event) # Dangerous!
return "OK"
# ✅ GOOD: Verify signature
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-SF-Signature')
if not verify_signature(request.get_data(), signature):
return "Invalid signature", 401
event = request.get_json()
process_event(event)
return "OK"
2. Return Quickly (< 5 seconds)
Process webhooks asynchronously to avoid timeouts:
from celery import Celery
celery_app = Celery('tasks', broker='redis://localhost:6379')
@celery_app.task
def process_event_async(event):
"""Process event in background."""
# Your slow processing logic here
send_email(event)
update_database(event)
call_external_api(event)
@app.route('/webhooks/geofence-events', methods=['POST'])
def handle_webhook():
# Verify signature
if not verify_signature(...):
return "Invalid", 401
event = request.get_json()
# Queue for async processing
process_event_async.delay(event)
# Return immediately
return jsonify({"status": "queued"}), 200
3. Handle Retries Gracefully
SpatialFlow retries failed webhooks up to 7 times:
@app.route('/webhooks/geofence-events', methods=['POST'])
def handle_webhook():
try:
event = request.get_json()
# Check if already processed (idempotency)
if is_duplicate(event['event_id']):
return jsonify({"status": "already_processed"}), 200
# Process event
process_event(event)
# Return 2xx to stop retries
return jsonify({"status": "success"}), 200
except Exception as e:
# Log error
print(f"Error processing webhook: {e}")
# Return 5xx to trigger retry
return jsonify({"error": str(e)}), 500
4. Set Rate Limits
Protect your webhook endpoint from overload:
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
app,
key_func=get_remote_address,
default_limits=["1000 per hour", "100 per minute"]
)
@app.route('/webhooks/geofence-events', methods=['POST'])
@limiter.limit("60 per minute")
def handle_webhook():
# Your webhook logic
pass
5. Monitor Webhook Health
Track webhook delivery success rates:
from prometheus_client import Counter, Histogram
webhook_received = Counter('webhooks_received_total', 'Total webhooks received')
webhook_processed = Counter('webhooks_processed_total', 'Successfully processed webhooks')
webhook_failed = Counter('webhooks_failed_total', 'Failed webhook processing')
webhook_duration = Histogram('webhook_processing_seconds', 'Webhook processing time')
@app.route('/webhooks/geofence-events', methods=['POST'])
def handle_webhook():
webhook_received.inc()
with webhook_duration.time():
try:
# Process webhook
process_event(request.get_json())
webhook_processed.inc()
return "OK", 200
except Exception as e:
webhook_failed.inc()
return "Error", 500
Security Best Practices
1. Use HTTPS Only
Never use HTTP for webhook URLs in production:
# ❌ BAD
"url": "http://yourapp.com/webhook"
# ✅ GOOD
"url": "https://yourapp.com/webhook"
2. Validate Event Structure
Check for required fields before processing:
def validate_event(event):
"""Validate webhook event structure."""
required_fields = ['event_type', 'timestamp', 'geofence_name', 'device_name']
for field in required_fields:
if field not in event:
raise ValueError(f"Missing required field: {field}")
# Validate event type
valid_events = ['entry', 'exit']
if event['event_type'] not in valid_events:
raise ValueError(f"Invalid event type: {event['event_type']}")
return True
@app.route('/webhooks/geofence-events', methods=['POST'])
def handle_webhook():
event = request.get_json()
try:
validate_event(event)
except ValueError as e:
return jsonify({"error": str(e)}), 400
# Process valid event
process_event(event)
return "OK", 200
3. Implement IP Allowlisting (Optional)
For extra security, allowlist SpatialFlow's webhook IPs:
ALLOWED_IPS = ['52.1.2.3', '52.1.2.4'] # Example IPs
@app.before_request
def check_ip():
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
if client_ip not in ALLOWED_IPS:
return jsonify({"error": "Unauthorized IP"}), 403
Troubleshooting
Webhook Not Received
Check these common issues:
- Firewall blocking: Ensure your server accepts HTTPS traffic on port 443
- SSL certificate: Verify your SSL certificate is valid (no self-signed certs)
- Timeout: Endpoint must respond within 30 seconds
- Incorrect URL: Double-check webhook URL has no typos
Test connectivity:
curl -X POST https://your-app.com/webhooks/test \
-H "Content-Type: application/json" \
-d '{"test": "data"}'
Webhook Signature Verification Failing
Common causes:
- Wrong secret: Verify you're using the correct webhook secret
- Body modification: Don't parse JSON before verification - use raw body
- Character encoding: Use UTF-8 encoding
Debug verification:
print(f"Received signature: {signature}")
print(f"Expected signature: sha256={expected_signature}")
print(f"Raw body: {request.get_data()}")
Webhook Retries Exhausted
If webhooks fail all 7 retry attempts, check the Dead Letter Queue (DLQ):
# List failed webhooks
curl -X GET https://api.spatialflow.io/api/v1/webhooks/dlq \
-H "X-API-KEY: YOUR_API_KEY"
Next Steps
- Webhooks Concept Guide - Deep dive into webhook delivery
- Workflow Automation - Build complex workflows
- Error Handling - Handle webhook errors gracefully
- API Reference - Complete API documentation