Skip to main content

Workflows

Workflows are SpatialFlow's automation engine. A workflow connects a trigger to one or more actions, so when a device enters a geofence, a shift runs long, or a device stops reporting, SpatialFlow sends a webhook, a Slack message, an email or an SMS, typically within 2 seconds.

What is a Workflow?​

A workflow is an automation pipeline that connects a trigger to one or more actions. Think of it as: "When [something happens], do [these things]."

Trigger → Action 1 → Action 2 → Action 3

For example:

  • When a delivery truck enters the customer's geofence
  • Do send a webhook to your backend API and send a Slack message to the operations channel

Workflow Components​

Triggers​

A trigger's trigger_config.type picks which of these fires the workflow. This is the full set the API recognizes; the builder's palette groups some of them under one card.

In the palettetypeFires when
Geofencegeofence_enter, geofence_exit, geofence_any, geofence_dwellA device enters, exits, does either, or dwells inside a geofence
Aggregate Geofencegeofence_aggregateEntries, exits, or either reach a count within a rolling time window
Geofence Occupancygeofence_occupancyThe number of devices inside one geofence right now crosses a threshold. See Geofence Occupancy
Missed Check-inexpected_visitA device doesn't reach a checkpoint geofence during a daily window. See Missed Check-in
Device Offlinedevice_last_seenAn active-shift device stops reporting. See Device Offline
Device Stuckdevice_stuckAn active-shift device keeps reporting but doesn't move. See Device Stuck
Shift Overdueshift_overdueA shift runs longer than a set number of hours. See Shift Overdue
Route Deviationsignal with signal_types: ["route_deviation"]A device on an active trip strays outside its planned route corridor
Scheduleschedule_cron, schedule_intervalA cron expression or a fixed interval, independent of any device event
Webhookwebhook_receivedReserved for an inbound trigger that nothing fires yet, so a workflow using it never runs. The API refuses to save a new one. An existing workflow that has one can still be edited while inactive, switched to another trigger, deactivated or deleted. Activating one is refused. While one is active, the builder can't save it, even for a rename, until you deactivate it or switch it to another trigger
Event Attributeattribute_checkReserved for a trigger that matches conditions on event data. Nothing fires it yet, so a workflow using it never runs. The API refuses to save a new one. An existing workflow that has one can still be edited while inactive, switched to another trigger, deactivated or deleted. Activating one is refused. While one is active, the builder can't save it, even for a rename, until you deactivate it or switch it to another trigger
(manual runs only)manualThe Run Now button in the builder. Not offered as a palette node

Geofence Entry, Exit, Any, Dwell​

{
"type": "geofence_enter",
"target_mode": "geofences",
"geofence_ids": ["a1b2c3d4-..."],
"device_filters": {
"device_types": ["vehicle"]
}
}
  • target_mode: geofences (default) or tags. With tags, set geofence_tag_ids instead of geofence_ids; the trigger then covers every geofence carrying that tag, including ones added later.
  • geofence_ids / geofence_tag_ids: at least one is required for geofence_enter, geofence_exit, geofence_any and geofence_dwell (or a group_ids list, the legacy Groups fallback). geofence_aggregate alone can start empty, matching every geofence.
  • device_filters (optional): device_ids, exclude_device_ids, device_types, groups, metadata_match.
  • geofence_dwell also takes dwell_threshold, in seconds (30 to 86400). The builder's Node Inspector authors this as Dwell time in minutes and converts it for you.

There is no debounce setting on the trigger itself. LocationFilterService smooths raw GPS points before any trigger evaluation runs (a 25 m minimum movement, a 30 s minimum between state transitions, and more); see GPS Jitter Filtering.

Aggregate Geofence​

{
"type": "geofence_aggregate",
"geofence_ids": ["a1b2c3d4-..."],
"event_type": "entry",
"threshold": 40,
"time_window": 900,
"count_type": "unique",
"cooldown_period": 900
}

event_type is entry, exit or any. time_window is seconds, up to 3600 (1 hour). count_type is unique devices or total events; default unique. cooldown_period defaults to time_window and holds off re-firing once the threshold is reached.

Route Deviation and other signals​

{
"type": "signal",
"signal_types": ["route_deviation"],
"signal_states": ["confirmed"],
"geofence_ids": ["a1b2c3d4-..."]
}

signal_types is required; signal_states defaults to ["confirmed"] and accepts started, confirmed, ended, resolved. The Route Deviation palette card is this trigger pre-filled with signal_types: ["route_deviation"]. Other signal types (dwell, policy_violation) work the same way; see Signals for the full list, lifecycle and explainability fields.

Schedule​

{ "type": "schedule_cron", "cron_expression": "0 9 * * 1-5" }
{ "type": "schedule_interval", "interval_minutes": 30 }

schedule_cron takes a standard 5-field cron expression, read in the workspace's timezone, or in UTC when the workspace has none set. schedule_interval takes interval_minutes, at least 1. Missed runs aren't replayed: if a schedule misses several runs, during an outage for example, it runs once for the most recent one and then carries on as scheduled. Neither depends on a device event, so this is the trigger for a recurring report or a heartbeat check, not for gating an existing geofence trigger to certain hours; see Time windows below for that.

Conditions​

A condition step gates everything downstream: when it evaluates false, the branch after it is skipped, not failed. Add one from the Conditions tab and connect it between a trigger (or another step) and the actions it should guard.

In the paletteconditionTypeChecks
Speed CheckspeedThe device's current speed against a threshold
Distance CheckdistanceDistance from a reference point
Business HourstimeA time-of-day and day-of-week window
Device Propertydevice_propertyA device attribute, such as battery level
Data CheckdataA field on the trigger's event data

Two more condition types have no palette card but are accepted by the API: geospatial (a polygon or radius check independent of geofence membership) and dwell_time (time spent at the current location). A conditionType with no matching evaluator is rejected at save time, so a typo never saves as a silently-dead gate.

Time windows​

Three different mechanisms restrict when something fires, and they are not interchangeable:

  • A geofence's own schedule (Active Days, Start Time, End Time) makes the geofence itself invisible to every trigger outside that window. Set it on the geofence, not the workflow. See Limit a geofence to set hours.
  • The Schedule trigger (schedule_cron / schedule_interval) runs a workflow on a clock, with no device event at all.
  • The Business Hours condition (conditionType: "time") gates the actions after any trigger to a time-of-day and day-of-week window:
{
"conditionType": "time",
"config": {
"timeWindow": {
"start": "09:00",
"end": "17:00",
"days": ["Mon", "Tue", "Wed", "Thu", "Fri"],
"timezone": "America/New_York"
}
}
}

start and end are HH:MM; a start after the end wraps overnight. days uses three-letter abbreviations and is optional (every day if omitted). timezone is optional too: an omitted zone reads the workflow's workspace timezone, and only falls back to UTC when that is also unset.

Actions​

Actions define what happens when a workflow is triggered. Chain multiple actions in sequence, or branch them behind a condition. A step's type must be one the engine can run: webhook, email, sms, slack, aws_sns, aws_lambda, teams, discord, sendgrid or database, plus the condition step covered above. Any other type is rejected with "Invalid step type".

Microsoft Teams, Discord, SendGrid, AWS Lambda and database actions run only through an integration, and appear in the builder once you connect the matching one (see Send alerts to Slack, Microsoft Teams or Discord). Through the API, set the step's integration_id to that integration; the step then sends through it, and its type must match the integration's type. A step of one of these types without an integration_id is rejected with "Select an integration for this type step".

A step can't store credentials. It is saved as written and returned by the API, so API keys, tokens, passwords, AWS keys, a Slack webhook_url or bot_token, a Twilio account_sid or auth_token, a PagerDuty routing_key, integration_key or service_key, webhook auth secrets, a signing_secret and credential headers such as Authorization, Cookie, X-API-Key or any header whose name contains token, secret or password belong in an integration. That holds at any depth in the step, including beside its config and inside a webhook body or Lambda payload, even when a template such as {{trigger.count}} leaves its JSON incomplete, and in any URL in the step, of any scheme and anywhere in a string, such as the webhook URL, a link in its body or a postgresql://, amqp:// or ftp:// connection string: a password before the host (for http and https, a user alone too), a query parameter whose name contains token, secret, password, api_key, credential or another name the API treats as secret (so page_token counts too), passwd, or the signature of an AWS or Google Cloud signed URL (X-Amz-Signature, X-Goog-Signature and their credentials), or a webhook URL whose path or host is its secret. Those are Slack (hooks.slack.com/services/..., /workflows/... and /triggers/...), Discord (discord.com/api/webhooks/...), Microsoft Teams and Office 365 connectors (*.webhook.office.com/webhookb2/..., outlook.office.com/webhook/...), Zapier catch hooks, IFTTT Maker, Make, Pipedream endpoints, the Telegram bot API, PagerDuty integration URLs and Google Apps Script web apps; the message names them as the webhook secret in url. Such a URL counts however its path is written, including with dot segments (/./, /x/../), repeated slashes, escaped letters (/%73ervices/) or a host with a trailing dot. On the hosts that carry their secret in a generic query parameter, that parameter counts too: Google Chat's key (chat.googleapis.com), the sig of Azure Logic Apps, Power Automate and Azure Storage SAS URLs, an Azure Functions code and a Firebase Realtime Database auth. Elsewhere key, sig, code and auth are ordinary parameters. A part of a URL that templates supply entirely, such as a {{trigger.token}} path segment or query value, doesn't count when each template names a variable delivery fills in: trigger or step_N data, workflow.id, workflow.name, execution.id, execution.started_at or a legacy name such as device.id, with or without spaces inside the braces. It exempts nothing beside it: a literal password next to a templated user still counts. Anything else in braces, such as {{my-key}}, is sent as written, so it counts like the text around it. Nor does the service's host alone. Names match in any case and with -, _ or camelCase, so clientSecret, privateKey and routingKey count too. Only a text value counts, so a flag or a number under a name like password_reset or token_count saves. A save that adds one is rejected with "Steps can't store credentials; move key into an integration for this type step", where key is its path, such as body.api_key or url?api_key. A workflow that already stores one keeps saving while that credential is unchanged, until you move it. A URL that must carry its secret, such as a Slack, Discord or Google Chat webhook, belongs in a Webhook integration: the integration keeps its URL write-only, so reads return only its scheme and host.

The same rules cover the trigger's config and the builder layout saved with a workflow's nodes, since the API returns both as stored. A save that adds a credential there is rejected with "Workflows can't store credentials; remove path", such as trigger_config.api_key or nodes[0].data.api_key, and one already stored is kept while it's unchanged.

Some settings matter only once a workflow runs. A draft saves without them; activating the workflow, or saving it while it's active, is rejected until they're set:

  • An sms or slack step with no integration. SpatialFlow has no shared Twilio or Slack account to send with, so connect one.
  • A blank recipient (to), subject or body (body or html_body) in an email step. A list of recipients counts as blank when any entry is.
  • A value the action refuses, in the step or in the integration it sends through, such as an invalid email address or phone number, a Slack channel that isn't a #channel, an @user or a channel ID, a Lambda ARN that doesn't start with arn:aws:lambda:, a database table name with anything but letters, numbers, _ and a schema dot, or a database port outside 1 to 65535. Email, SMS, Slack, SNS, Lambda and SendGrid actions check these as written, before templates are filled in, so a recipient such as {{trigger.email}} is refused too. Teams, Discord and database actions check them after, so a setting filled from a template variable there is left to delivery, each such setting on its own, while every literal value is still checked, including one beside a template inside an embed or card, and braces that name no variable. A database host is checked only at delivery.
  • An email step whose provider it can't send with: integration before an integration is picked, sendgrid without one (a step can't store the API key), spatialflow with a from_email, or anything other than smtp, spatialflow or sendgrid.
  • The fields a linked step's action reads from the step: email to, subject and body (or html_body); Teams team_id, channel_id and message (or adaptive_card); Discord channel_id and content (or an embed); Lambda function_arn and payload; SendGrid to, plus subject and content unless template_id is set; SMS to and message; Slack message.
  • A setting either the step or its integration can hold: a database table_name, a SendGrid from_email, and a Slack channel when the integration posts with a bot token rather than an incoming webhook.

Webhook (HTTP Request)​

Send HTTP requests to external APIs with custom headers, authentication, and payloads.

Supported Methods: GET, POST, PUT, PATCH, DELETE

Features:

  • Custom headers and request body
  • Authentication (basic, bearer token or API key) through a webhook integration, since a step can't store credentials
  • Variable substitution from trigger data
  • Signing is opt-in: attach a webhook integration with a webhook_secret to get an X-SpatialFlow-Signature header, HMAC-SHA256 of <timestamp>.<body>, plus an X-SpatialFlow-Timestamp header. No secret means no signature header. This is a separate contract from the workspace webhook's always-on X-SF-Signature; see Workflow Webhook action vs. workspace webhook
  • retry_count (0 to 10) sets how many times a failed request retries: 0 sends once, and 1 to 10 gives 2 to 11 attempts. Without it, the action makes up to 3 attempts. Only connection failures, timeouts and 5xx or 429 responses are retried; any other response, such as a 400 or 404, ends the step on the first attempt. There is no Dead Letter Queue for this action; a step that exhausts its retries just fails the run
  • Configurable timeout, up to 30 seconds

Verifying the signature. When a signing secret is set and the request has a body, the action sends X-SpatialFlow-Timestamp (Unix seconds) and X-SpatialFlow-Signature. The signature is sha256= followed by the hex HMAC-SHA256, keyed with the secret, of the timestamp, a dot, and the exact body that was sent. Recompute it over the raw body you received, and reject a missing, malformed, or stale timestamp so a captured request cannot be replayed later.

import hashlib
import hmac
import time

MAX_AGE_SECONDS = 300 # reject requests older than 5 minutes
MAX_FUTURE_SKEW_SECONDS = 30


def verify_workflow_webhook(body: bytes, timestamp: str, signature: str, secret: str) -> bool:
try:
sent_at = int(timestamp)
except (TypeError, ValueError):
return False
age = time.time() - sent_at
if age > MAX_AGE_SECONDS or age < -MAX_FUTURE_SKEW_SECONDS:
return False

signed = timestamp.encode() + b"." + body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# Compare bytes: str comparison raises TypeError on a non-ASCII header.
return hmac.compare_digest(expected.encode(), (signature or "").encode())

A save that sets a method, header, auth, timeout or retry_count the action can't send with is rejected, with the reason. A workflow that already stores one keeps saving as a draft while that setting is unchanged, but it can't be activated, and a save that leaves it active is rejected, until the setting is fixed. The validity audit lists it.

Example Configuration:

{
"type": "webhook",
"name": "Send arrival notification",
"config": {
"url": "https://api.example.com/webhooks/arrival",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"body": {
"device_id": "{{trigger.device_id}}",
"geofence_name": "{{trigger.geofence_name}}",
"timestamp": "{{trigger.timestamp}}",
"location": {
"lat": "{{trigger.location.latitude}}",
"lng": "{{trigger.location.longitude}}"
}
},
"timeout": 10,
"retry_count": 3
}
}

Slack Notification​

Send messages to a Slack destination with rich formatting.

Features:

  • Webhook-based authentication via Integrations
  • Incoming-webhook delivery to the destination selected when the Slack webhook was created
  • Channel or user targeting when using a bot-token/OAuth delivery path
  • Rich message formatting with Markdown
  • Block Kit support for interactive messages
  • Thread support for organizing conversations

Example Configuration:

{
"type": "slack",
"name": "Notify operations team",
"config": {
"integration_id": "int_abc123",
"message": "Vehicle {{trigger.device_id}} arrived at {{trigger.geofence_name}}"
}
}
Slack via Integrations

integration_id names a Slack integration from your workspace settings. The integration holds the webhook URL or bot token, so the step never carries it, and a step that sets webhook_url or bot_token itself is rejected. A Slack step without an integration saves as a draft but can't be activated.

Incoming webhooks are channel-bound

Slack incoming webhooks post to the channel selected in Slack when the webhook is created. Create one webhook/integration per destination channel. Use channel or default_channel only with bot-token/OAuth delivery paths that can select a channel at send time.

Email​

Send email notifications using AWS SES (via django-ses).

Features:

  • HTML and plain text email support
  • Template variables for dynamic content
  • Multiple recipients (to, cc, bcc)
  • Custom sender names

Example Configuration:

{
"type": "email",
"name": "Send arrival email",
"config": {
"integration_id": "int_xyz789",
"to": ["manager@example.com"],
"subject": "Arrival at {{trigger.geofence_name}}",
"html_body": "<p>Device {{trigger.device_id}} has arrived at {{trigger.geofence_name}} at {{trigger.timestamp}}.</p>",
"body": "Device {{trigger.device_id}} arrived at {{trigger.geofence_name}}"
}
}

SMS (Twilio)​

Send SMS notifications via Twilio integration.

Features:

  • International phone number support
  • Template variables for dynamic content
  • Delivery tracking and status updates
  • Support for multiple recipients

Example Configuration:

{
"type": "sms",
"name": "Send arrival SMS",
"config": {
"integration_id": "int_twilio123",
"to": "+14155551234",
"message": "Your delivery has arrived at {{trigger.geofence_name}}"
}
}

Database Operations​

Insert workflow event data into an external database (PostgreSQL or MySQL) for custom data persistence. Uses actionType: "database".

Features:

  • Parameterized INSERT only, no UPDATE, DELETE, or raw SQL
  • Target table is set with table_name; column-value payload is set with data (falls back to the trigger payload when data is omitted)
  • Identifier allowlist enforced on table_name / column keys to prevent SQL injection
  • Per-workflow database integration (host, port, credentials, SSL) stored separately from the step config

Example Configuration:

{
"type": "database",
"name": "Log geofence entry",
"config": {
"integration_id": "int_pg_prod",
"table_name": "geofence_events",
"data": {
"device_id": "{{trigger.device_id}}",
"geofence_name": "{{trigger.geofence_name}}",
"event_type": "{{trigger.event_type}}",
"occurred_at": "{{trigger.timestamp}}"
}
}
}

AWS SNS​

Publish to an SNS topic, or send SMS directly through SNS, using actionType: "aws_sns". Requires an AWS integration; a role-assuming (role_arn-only) integration isn't supported yet for this action, so the integration needs aws_access_key_id and aws_secret_access_key.

Example Configuration:

{
"type": "aws_sns",
"name": "Publish arrival event",
"config": {
"integration_id": "int_aws_prod",
"topic_arn": "arn:aws:sns:us-east-1:123456789012:arrivals",
"message": "{{trigger.device_name}} arrived at {{trigger.geofence_name}}"
}
}

Set phone_number (E.164 format, for example +14155551234) instead of topic_arn to send an SMS directly through SNS rather than publishing to a topic. message_structure: "json" sends a per-protocol message map instead of one string; it requires a default key.

Creating Workflows​

Via Visual Builder​

The SpatialFlow web application includes a no-code workflow builder with a drag-and-drop canvas interface powered by React Flow.

Steps:

  1. Navigate to Workflows in the dashboard
  2. Click Create Workflow
  3. Drag a trigger node onto the canvas from the Triggers tab, or click Start with a template
  4. Configure the trigger (select geofences or tags, and any filters)
  5. Drag Action nodes and connect them to the trigger
  6. Configure each action with the required settings
  7. Test your workflow with the built-in testing tool
  8. Save and activate your workflow

Features:

  • Visual workflow canvas with zoom and pan
  • Collapsible component palette
  • Real-time configuration inspector
  • Auto-save and draft recovery
  • Workflow templates for common patterns
  • Test mode with live execution monitoring

Via API​

Create workflows programmatically using the REST API.

Endpoint: POST /api/v1/workflows

Request:

curl -X POST https://api.spatialflow.io/api/v1/workflows \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Warehouse Arrival Alert",
"description": "Send notifications when trucks arrive at warehouse",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Geofence Entry",
"config": {
"geofence_ids": ["a1b2c3d4-5678-90ab-cdef-1234567890ab"]
}
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "Send to backend",
"config": {
"url": "https://api.example.com/arrivals",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"body": {
"device_id": "{{trigger.device_id}}",
"timestamp": "{{trigger.timestamp}}"
}
}
}
},
{
"id": "action-2",
"type": "action",
"data": {
"actionType": "slack",
"label": "Notify team",
"config": {
"integration_id": "int_slack123",
"message": "Truck {{trigger.device_id}} arrived"
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"},
{"id": "edge-2", "source": "action-1", "target": "action-2"}
]
}'

Response:

{
"id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"name": "Warehouse Arrival Alert",
"description": "Send notifications when trucks arrive at warehouse",
"status": "draft",
"version": 1,
"nodes": [...],
"edges": [...],
"run_count": 0,
"success_rate": null,
"user_id": "...",
"created_at": "2025-11-10T12:00:00Z",
"updated_at": "2025-11-10T12:00:00Z"
}

Workflow Execution​

When a trigger condition is met, SpatialFlow executes the workflow automatically:

  1. Event Detection: The platform monitors location updates and detects geofence transitions
  2. Workflow Matching: All active workflows matching the trigger are identified
  3. Execution Queue: A workflow execution job is queued to Celery for async processing
  4. Step Execution: Each action in the workflow executes in sequence
  5. Status Updates: Real-time status updates are sent via WebSocket
  6. Result Capture: Execution results, timing, and any errors are stored

Target Latency: < 2 seconds from trigger to action completion (p95)

Execution Context​

Each workflow execution has access to a context object containing:

  • trigger: The triggering event data (device_id, location, geofence details, timestamp)
  • workflow: Workflow metadata (id, name, version)
  • execution: Current execution context (id, started_at)
  • Step outputs: Accessible via step_0, step_1, etc., for subsequent steps

Example Template Variable Usage:

Device {{trigger.device_id}} entered {{trigger.geofence_name}} at {{trigger.timestamp}}
Location: {{trigger.location.latitude}}, {{trigger.location.longitude}}

Workflow Status & Monitoring​

Status States​

Workflows can be in one of three states:

  • Draft: Inactive workflow in test mode (not triggered by real events)
  • Active: Deployed workflow actively monitoring for trigger conditions
  • Paused: Inactive workflow that can be quickly reactivated

Execution History​

Every workflow execution is tracked with detailed information:

  • Execution ID: Unique identifier for each run
  • Status: pending, running, completed, failed, cancelled
  • Trigger Source: geofence, manual_test, schedule, webhook
  • Step Details: Individual step execution with timing and status
  • Duration: Total execution time in milliseconds
  • Error Messages: Detailed error information for failed executions

API Endpoint: GET /api/v1/workflows/{workflow_id}/executions

Real-time Monitoring​

The workflow builder includes a real-time debugger that shows:

  • Live execution progress
  • Step-by-step status updates via WebSocket
  • Input and output data for each step
  • Execution duration and performance metrics
  • Error messages and retry attempts

Best Practices​

Naming Conventions​

  • Use descriptive names that explain the purpose: "Warehouse Entry Alerts" instead of "Workflow 1"
  • Include the trigger type and primary action: "Geofence Exit → Webhook"
  • Add environment prefix for multi-environment setups: "PROD: Customer Arrivals"

Testing workflows​

Save your workflow, then open Test in the builder. Preview (no actions) evaluates the saved workflow on the server with the sample data you supply. It records a run and shows trigger variables and condition results, but does not send notifications or execute action handlers. Occupancy has a sample template with a device count, threshold, comparison, and geofence name; these are sample values, not a count of phones at your hotel.

Open Runs > View details to inspect the recorded preview. Actions are marked Not executed (preview). A successful preview does not verify credentials, provider delivery, or real-world trigger detection.

To verify delivery, switch the tester to LIVE EXECUTION and confirm the warning. Live mode can send messages and perform configured actions. Use test recipients and check the destination as well as the execution logs. Run Now also executes actions and requires an active workflow; for occupancy, its run details include the count captured when you clicked it.

The tester requires saved changes and valid JSON. Stop stops watching the run; it does not cancel work already queued on the server.

Error Handling​

  • Configure appropriate retry counts for webhook actions (default: 3)
  • Set realistic timeouts based on downstream API performance
  • Monitor execution history for patterns of failures
  • Use exponential backoff for retry policies
  • Configure dead-letter queues for terminal failures

Performance Considerations​

  • Keep action chains short (recommended: 3-5 actions maximum)
  • Use async actions when order doesn't matter
  • Minimize large payload sizes in webhook bodies
  • Cache frequently accessed data in action handlers
  • Monitor execution latency and optimize slow actions

Security​

  • Use integrations for storing credentials instead of hardcoding secrets
  • Enable webhook signature verification (HMAC)
  • Validate input data before using in templates
  • Follow least-privilege principle for IAM roles (AWS actions)
  • Regularly rotate API keys and tokens

Common Patterns​

Pattern 1: Delivery Arrival Notification​

Use Case: Notify customers when their delivery vehicle arrives

{
"name": "Customer Arrival Notification",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Delivery Zone Entry",
"config": { "geofence_ids": ["customer_delivery_zone"] }
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "Update backend",
"config": {
"url": "https://api.example.com/deliveries/arrival",
"method": "POST",
"body": {
"delivery_id": "{{trigger.device_id}}",
"customer_id": "{{trigger.geofence.metadata.customer_id}}",
"arrival_time": "{{trigger.timestamp}}"
}
}
}
},
{
"id": "action-2",
"type": "action",
"data": {
"actionType": "sms",
"label": "SMS customer",
"config": {
"to": "{{trigger.geofence.metadata.customer_phone}}",
"message": "Your delivery is arriving now!"
}
}
}
],
"edges": [
{ "id": "edge-1", "source": "trigger-1", "target": "action-1" },
{ "id": "edge-2", "source": "action-1", "target": "action-2" }
]
}

Pattern 2: Restricted Zone Alert​

Use Case: Alert security team when a vehicle enters a restricted area

{
"name": "Restricted Zone Entry Alert",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Restricted Zone Entry",
"config": { "geofence_ids": ["restricted_zone_1", "restricted_zone_2"] }
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "slack",
"label": "Alert security",
"config": {
"integration_id": "int_security_alerts",
"message": "ALERT: Vehicle {{trigger.device_id}} entered restricted zone {{trigger.geofence_name}} at {{trigger.timestamp}}"
}
}
},
{
"id": "action-2",
"type": "action",
"data": {
"actionType": "email",
"label": "Email security manager",
"config": {
"to": ["security@example.com"],
"subject": "Restricted Zone Entry Alert",
"body": "Vehicle {{trigger.device_id}} entered {{trigger.geofence_name}}"
}
}
}
],
"edges": [
{ "id": "edge-1", "source": "trigger-1", "target": "action-1" },
{ "id": "edge-2", "source": "action-1", "target": "action-2" }
]
}

Pattern 3: Multi-Action Webhook Fan-out​

Use Case: Trigger multiple downstream systems when a depot entry occurs

{
"name": "Depot Entry Multi-System Update",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Depot Entry",
"config": { "geofence_ids": ["main_depot"] }
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "Update ERP system",
"config": {
"url": "https://erp.example.com/api/vehicle-arrival",
"method": "POST"
}
}
},
{
"id": "action-2",
"type": "action",
"data": {
"actionType": "webhook",
"label": "Update fleet management",
"config": {
"url": "https://fleet.example.com/api/events",
"method": "POST"
}
}
}
],
"edges": [
{ "id": "edge-1", "source": "trigger-1", "target": "action-1" },
{ "id": "edge-2", "source": "trigger-1", "target": "action-2" }
]
}

Pattern 4: Time-Windowed Notification​

Use Case: Only send alerts during business hours

Add a Business Hours condition between the trigger and the action; see Time windows for the other two ways to restrict when a workflow fires.

{
"name": "Business Hours Arrival Alert",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Office Entry",
"config": { "geofence_ids": ["office_location"] }
}
},
{
"id": "condition-1",
"type": "condition",
"data": {
"conditionType": "time",
"label": "Business Hours",
"config": {
"timeWindow": {
"start": "09:00",
"end": "17:00",
"days": ["Mon", "Tue", "Wed", "Thu", "Fri"]
}
}
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "slack",
"label": "Notify reception",
"config": {
"integration_id": "int_reception_alerts",
"message": "Visitor arrived at front entrance"
}
}
}
],
"edges": [
{ "id": "edge-1", "source": "trigger-1", "target": "condition-1" },
{ "id": "edge-2", "source": "condition-1", "target": "action-1" }
]
}

Troubleshooting​

Workflow Not Triggering​

Symptoms: Workflow is active but not executing when expected

Solutions:

  1. Verify the workflow status is "active" (not "draft" or "paused")
  2. Check that is_test_mode is set to false for production
  3. Confirm geofences are correctly selected in trigger configuration
  4. Review location accuracy: LocationFilterService rejects fixes worse than 100 m and requires 25 m of movement between transitions
  5. If a Business Hours or other condition sits between the trigger and the action, confirm the event fell inside the condition's window
  6. Verify the device is sending location updates to SpatialFlow

Action Failing Repeatedly​

Symptoms: Workflow executes but specific action always fails

Solutions:

  1. Review execution logs for detailed error messages
  2. Test the action endpoint independently (e.g., curl for webhooks)
  3. Verify integration credentials are valid and not expired
  4. Check webhook URL is accessible from SpatialFlow servers
  5. Ensure request payload is valid for the destination API
  6. Review timeout settings: increase if the downstream API is slow
  7. Check rate limits on destination service

Slow Workflow Execution​

Symptoms: Workflow takes longer than 2 seconds to complete

Solutions:

  1. Review execution timing for each step to identify bottleneck
  2. Increase timeout for slow webhook endpoints
  3. Optimize downstream API performance
  4. Consider parallelizing independent actions (future feature)
  5. Reduce payload size by removing unnecessary data
  6. Check for network latency between SpatialFlow and action endpoints

Template Variables Not Rendering​

Symptoms: Action receives literal {{variable}} instead of actual value

Solutions:

  1. Verify the variable path is correct (e.g., trigger.device_id)
  2. Check that the trigger data contains the expected fields
  3. Use the workflow tester to inspect trigger data structure
  4. Ensure JSON is properly formatted in webhook body
  5. Review template syntax documentation for correct format

Integration Not Found​

Symptoms: Action fails with "Integration not found" error

Solutions:

  1. Verify the integration_id exists and is active
  2. Check that the integration belongs to your workspace
  3. Ensure the integration type matches the action type
  4. Verify the integration credentials are valid
  5. Verify the user has permission to use the integration

Next Steps​