Skip to main content

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

Slack Integration

Send geofence events to Slack channels using Incoming Webhooks.

Step 1: Create Slack Incoming Webhook

  1. Go to Slack API
  2. Click "Create New App" → "From scratch"
  3. Enable "Incoming Webhooks"
  4. Click "Add New Webhook to Workspace"
  5. Select channel and authorize
  6. Copy the webhook URL

Slack binds each incoming webhook URL to the selected channel. To post to a different channel, create a separate webhook or use a bot-token/OAuth Slack integration that can select channels at send time.

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}}"
}
}
}
],
"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

  1. Open Discord server settings
  2. Go to Integrations → Webhooks
  3. Click "New Webhook"
  4. Name it and select channel
  5. 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": {
"url": "https://discord.com/api/webhooks/YOUR/WEBHOOK",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"body": {
"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

Use the dedicated Teams action for posting to Microsoft Teams channels. SpatialFlow's Teams handler uses the Microsoft Graph API with OAuth, so you authorize a Teams integration in SpatialFlow once and then reference the team and channel by ID in your workflow.

Step 1: Connect Microsoft Teams in SpatialFlow

  1. Open the SpatialFlow web app → Integrations → Add integration → Microsoft Teams
  2. Complete the Microsoft OAuth consent flow
  3. Capture the team_id and channel_id for the channel you want to post to (the integration UI surfaces both)

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": "teams",
"label": "Teams Notification",
"config": {
"team_id": "your-team-id",
"channel_id": "your-channel-id",
"message": "Geofence Entry Alert: {{trigger.device_name}} entered {{trigger.geofence_name}}"
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"}
]
}'

Required Teams config fields: team_id, channel_id, message. Optional fields: adaptive_card, importance (normal, high, or urgent), mentions, content_type (text or html).

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)

  1. Go to webhook.site
  2. Copy your unique URL
  3. Use it in your workflow webhook action
  4. 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:

  1. Firewall blocking: Ensure your server accepts HTTPS traffic on port 443
  2. SSL certificate: Verify your SSL certificate is valid (no self-signed certs)
  3. Timeout: Endpoint must respond within 30 seconds
  4. 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:

  1. Wrong secret: Verify you're using the correct webhook secret
  2. Body modification: Don't parse JSON before verification - use raw body
  3. 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