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
Two webhook systems — this guide uses the workflow Webhook action

SpatialFlow has two distinct outbound-webhook paths with different signing and retry contracts. Every example in this guide builds the first one:

  • Workflow Webhook action (an action node inside a workflow — what you see below). Signing is opt-in: add a signing_secret to the action config and SpatialFlow sends an X-SpatialFlow-Signature header (HMAC-SHA256 of <timestamp>.<body>) together with an X-SpatialFlow-Timestamp header. With no signing_secret, no signature header is sent at all. The action is retried a small, configurable number of times (default 3 attempts; set retry_count, max 10) and has no Dead Letter Queue.
  • Standalone Webhooks app (configured directly on a geofence, not through a workflow). It signs every delivery with X-SF-Signature (HMAC-SHA256 of the raw body, no timestamp), retries up to 7 times, and moves permanent failures to a DLQ. That contract is documented in the Webhooks concept guide — do not mix the two.

The receiver snippets in this guide verify the workflow action's X-SpatialFlow-Signature.

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",
"signing_secret": "your_webhook_secret_here",
"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, timestamp, signature):
"""Verify the workflow Webhook action's X-SpatialFlow-Signature.

The action signs `<timestamp>.<raw_body>` (not the raw body alone).
"""
if not timestamp or not signature:
return False

signed_payload = f"{timestamp}.".encode('utf-8') + payload
expected_signature = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signed_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 (workflow action sends both headers when signing_secret is set)
timestamp = request.headers.get('X-SpatialFlow-Timestamp')
signature = request.headers.get('X-SpatialFlow-Signature')
if not verify_signature(payload, timestamp, 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, timestamp, signature) {
// The workflow action signs `<timestamp>.<raw_body>`, not the raw body alone.
if (!timestamp || !signature) return false;

const expectedSignature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${timestamp}.${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 (both headers are sent when signing_secret is configured)
const timestamp = req.headers["x-spatialflow-timestamp"];
const signature = req.headers["x-spatialflow-signature"];
if (!verifySignature(req.rawBody, timestamp, 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():
timestamp = request.headers.get('X-SpatialFlow-Timestamp')
signature = request.headers.get('X-SpatialFlow-Signature')
if not verify_signature(request.get_data(), timestamp, 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

A workflow Webhook action retries a failed delivery a small, configurable number of times (default 3 attempts; set retry_count in the action config, up to 10). There is no Dead Letter Queue for workflow actions — a delivery that exhausts its attempts is recorded as a failed step in the workflow's run history. Still make your handler idempotent, since retries can re-deliver the same event:

@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 same value as the action's signing_secret
  2. Body modification: Don't parse JSON before verification - use raw body
  3. Missing timestamp: The workflow action signs <timestamp>.<raw_body>, so you must read X-SpatialFlow-Timestamp and prepend it before hashing (not the raw body alone)
  4. Wrong header name: Read the signature from X-SpatialFlow-Signature, not X-SF-Signature (that header belongs to the standalone Webhooks app)
  5. Character encoding: Use UTF-8 encoding

Debug verification:

print(f"Received signature: {signature}")
print(f"Received timestamp: {timestamp}")
print(f"Expected signature: sha256={expected_signature}")
print(f"Signed string: {timestamp}.{request.get_data().decode()}")

Webhook Retries Exhausted

When a workflow Webhook action exhausts its retries, the failure is recorded on the workflow execution (run history), not in a Dead Letter Queue. Inspect the failed step there:

# List recent executions for the workflow, then read the failed one
curl -X GET "https://api.spatialflow.io/api/v1/workflows/{workflow_id}/executions" \
-H "X-API-KEY: YOUR_API_KEY"

The Dead Letter Queue (/api/v1/webhooks/dlq) belongs to the standalone Webhooks app (geofence-attached webhooks), not to workflow Webhook actions. See the Webhooks concept guide.

Next Steps