Field Service Tracking Guide
Build an end-to-end field service tracking system with SpatialFlow. This guide walks through creating service area geofences for job sites, dispatching technicians with automated arrival/departure tracking, triggering job status updates, monitoring time-on-site, and receiving completion notifications via webhooks.
Use Case
Manage field technicians visiting customer locations:
- Create service area geofences for job sites and customer locations
- Dispatch technicians with automated arrival/departure tracking
- Trigger job status updates when technicians arrive at or leave job sites
- Monitor technician movements and calculate time-on-site
- Receive automated completion notifications via webhooks
Architecture Overview
Dispatch System --> Assign Job --> SpatialFlow API
|
Create Job Site Geofence
|
Technician App --> Location Updates --> Geofence Engine
|
Workflow Triggers
|
Webhook --> Dispatch Backend
|
Job Status Dashboard
Step 1: Create Service Area Geofences
Create geofences around each job site so SpatialFlow can detect when technicians arrive and depart.
Job Site Geofence
Create a circular service area (polygon approximating a 200m radius) around a customer address:
curl -X POST https://api.spatialflow.io/api/v1/geofences \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Job Site - 425 Park Ave HVAC Repair",
"description": "Service area for HVAC repair at 425 Park Avenue",
"geometry": {
"type": "Polygon",
"coordinates": [[
[-73.9717, 40.7580],
[-73.9700, 40.7590],
[-73.9680, 40.7590],
[-73.9663, 40.7580],
[-73.9663, 40.7565],
[-73.9680, 40.7555],
[-73.9700, 40.7555],
[-73.9717, 40.7565],
[-73.9717, 40.7580]
]]
},
"metadata": {
"job_id": "JOB-2025-1847",
"customer_name": "Meridian Office Group",
"service_type": "hvac_repair",
"priority": "high",
"scheduled_time": "2025-11-15T09:00:00Z"
}
}'
Response:
{
"id": "c3d4e5f6-a7b8-9012-cdef-234567890123",
"name": "Job Site - 425 Park Ave HVAC Repair",
"geometry": { "type": "Polygon", "coordinates": [[ ... ]] },
"metadata": {
"job_id": "JOB-2025-1847",
"customer_name": "Meridian Office Group",
"service_type": "hvac_repair",
"priority": "high",
"scheduled_time": "2025-11-15T09:00:00Z"
},
"is_active": true,
"created_at": "2025-11-14T16:30:00Z"
}
Regional Service Territory
Create a larger geofence for a regional coverage area to track which territory a technician is operating in:
curl -X POST https://api.spatialflow.io/api/v1/geofences \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Service Territory - Manhattan Midtown",
"description": "Regional service coverage for Midtown Manhattan",
"geometry": {
"type": "Polygon",
"coordinates": [[
[-73.9950, 40.7480],
[-73.9950, 40.7650],
[-73.9680, 40.7650],
[-73.9680, 40.7480],
[-73.9950, 40.7480]
]]
},
"metadata": {
"territory_code": "MAN-MID",
"region": "manhattan",
"assigned_team": "team_alpha",
"max_technicians": 12
}
}'
Response:
{
"id": "a7b8c9d0-e1f2-3456-0123-789012abcdef",
"name": "Service Territory - Manhattan Midtown",
"geometry": { "type": "Polygon", "coordinates": [[ ... ]] },
"metadata": {
"territory_code": "MAN-MID",
"region": "manhattan",
"assigned_team": "team_alpha",
"max_technicians": 12
},
"is_active": true,
"created_at": "2025-11-14T16:35:00Z"
}
Step 2: Set Up Dispatch Workflows
Create workflows to automate job tracking when technicians arrive at and depart from job sites.
Technician Arrival Workflow (Entry)
curl -X POST https://api.spatialflow.io/api/v1/workflows \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Technician Arrival - Park Ave Job Site",
"description": "Log technician arrival and update job status",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_enter",
"label": "Geofence Entry",
"config": {
"geofence_ids": ["c3d4e5f6-a7b8-9012-cdef-234567890123"]
}
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "HTTP Request",
"config": {
"url": "https://your-backend.com/api/technician-arrival",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_BACKEND_API_KEY"
},
"body": {
"technician_id": "{{trigger.device_id}}",
"technician_name": "{{trigger.device_name}}",
"job_site_id": "{{trigger.geofence_id}}",
"job_id": "{{trigger.geofence.metadata.job_id}}",
"customer_name": "{{trigger.geofence.metadata.customer_name}}",
"arrival_time": "{{trigger.timestamp}}",
"location": {
"lat": "{{trigger.location.latitude}}",
"lng": "{{trigger.location.longitude}}"
}
}
}
}
},
{
"id": "action-2",
"type": "action",
"data": {
"actionType": "slack",
"label": "Slack Notification",
"config": {
"webhook_url": "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK",
"message": "Technician {{trigger.device_name}} arrived at {{trigger.geofence_name}} for job {{trigger.geofence.metadata.job_id}}"
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"},
{"id": "edge-2", "source": "action-1", "target": "action-2"}
]
}'
{{trigger.geofence.metadata.*}}SpatialFlow populates the geofence.metadata portion of the trigger payload from the geofence's metadata JSON field at trigger time. Any key you add to a geofence's metadata becomes available to that geofence's triggers via {{trigger.geofence.metadata.<key>}} — for example, {{trigger.geofence.metadata.job_id}} resolves to whatever value you stored under job_id when you created the geofence.
Technician Departure Workflow (Exit)
curl -X POST https://api.spatialflow.io/api/v1/workflows \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Technician Departure - Park Ave Job Site",
"description": "Log departure and calculate time-on-site",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"triggerType": "geofence_exit",
"label": "Geofence Exit",
"config": {
"geofence_ids": ["c3d4e5f6-a7b8-9012-cdef-234567890123"]
}
}
},
{
"id": "action-1",
"type": "action",
"data": {
"actionType": "webhook",
"label": "HTTP Request",
"config": {
"url": "https://your-backend.com/api/technician-departure",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_BACKEND_API_KEY"
},
"body": {
"technician_id": "{{trigger.device_id}}",
"job_site_id": "{{trigger.geofence_id}}",
"job_id": "{{trigger.geofence.metadata.job_id}}",
"departure_time": "{{trigger.timestamp}}",
"service_type": "{{trigger.geofence.metadata.service_type}}"
}
}
}
},
{
"id": "action-2",
"type": "action",
"data": {
"actionType": "slack",
"label": "Slack Notification",
"config": {
"webhook_url": "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK",
"message": "Technician {{trigger.device_name}} departed from {{trigger.geofence_name}} - job {{trigger.geofence.metadata.job_id}} visit complete"
}
}
}
],
"edges": [
{"id": "edge-1", "source": "trigger-1", "target": "action-1"},
{"id": "edge-2", "source": "action-1", "target": "action-2"}
]
}'
Step 3: Register Technicians as Devices
Register each field technician as a device in SpatialFlow. The technician's mobile app will send location updates under this device identity.
curl -X POST https://api.spatialflow.io/api/v1/devices \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"device_id": "tech_201",
"name": "Sarah Johnson - HVAC Specialist",
"metadata": {
"employee_id": "EMP-4521",
"specialization": "hvac",
"vehicle_plate": "NY-TRK-8842",
"phone": "+1-555-0147",
"certification_level": "senior"
}
}'
Response:
{
"id": "d_tech_201_uuid",
"device_id": "tech_201",
"name": "Sarah Johnson - HVAC Specialist",
"metadata": {
"employee_id": "EMP-4521",
"specialization": "hvac",
"vehicle_plate": "NY-TRK-8842",
"phone": "+1-555-0147",
"certification_level": "senior"
},
"is_active": true,
"created_at": "2025-11-14T17:00:00Z"
}
Step 4: Send Location Updates
Send location updates from the technician's mobile app to SpatialFlow.
Location updates are only accepted while the technician's device has an active shift. If the app POSTs to /devices/{device_uuid}/location before the shift is started, the request is rejected with 400 {"detail": "Shift is not active"}. Have the app start the shift when the technician goes on duty:
curl -X POST https://api.spatialflow.io/api/v1/devices/{device_uuid}/start-shift \
-H "X-API-KEY: YOUR_API_KEY"
The shift stays active until the app calls /devices/{device_uuid}/end-shift, so this is a once-per-shift call — not part of every location update.
Single Location Update
Use the device UUID (id) returned from the registration response:
curl -X POST https://api.spatialflow.io/api/v1/devices/{device_uuid}/location \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"latitude": 40.7580,
"longitude": -73.9690,
"accuracy": 8,
"speed": 0,
"heading": 0,
"timestamp": "2025-11-15T09:12:00Z",
"metadata": {
"battery_level": 82
}
}'
Mobile App Integration (React Native Example)
Integrate location tracking into your technician app with 30-second intervals for active job tracking:
import * as Location from "expo-location";
// Request location permissions
const { status } = await Location.requestForegroundPermissionsAsync();
// Start tracking with 30-second intervals during active jobs
Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 30000, // Every 30 seconds
distanceInterval: 20, // Every 20 meters
},
async (location) => {
await fetch(
"https://api.spatialflow.io/api/v1/devices/{device_uuid}/location",
{
method: "POST",
headers: {
"X-API-KEY": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
latitude: location.coords.latitude,
longitude: location.coords.longitude,
accuracy: location.coords.accuracy,
speed: location.coords.speed, // expo reports m/s; the API expects m/s
heading: location.coords.heading,
timestamp: new Date(location.timestamp).toISOString(),
metadata: {
battery_level: await getBatteryLevel(),
},
}),
},
);
},
);
Step 5: Handle Webhook Events
Build backend endpoints to process technician arrival and departure events from SpatialFlow.
Express (Node.js) Backend
const express = require("express");
const app = express();
app.use(express.json());
// Technician arrival endpoint
app.post("/api/technician-arrival", async (req, res) => {
const {
technician_id,
technician_name,
job_site_id,
job_id,
customer_name,
arrival_time,
location,
} = req.body;
// Log arrival to database
await db.jobVisits.insert({
technician_id,
job_id,
job_site_id,
arrival_time: new Date(arrival_time),
latitude: location.lat,
longitude: location.lng,
status: "on_site",
});
// Update job status to in_progress
await db.jobs.update(job_id, {
status: "in_progress",
assigned_technician: technician_id,
actual_arrival: new Date(arrival_time),
});
// Calculate ETA vs actual arrival
const job = await db.jobs.findById(job_id);
const scheduledTime = new Date(job.scheduled_time);
const actualTime = new Date(arrival_time);
const delayMinutes = (actualTime - scheduledTime) / 60000;
if (delayMinutes > 15) {
console.log(
`WARNING: ${technician_name} arrived ${delayMinutes.toFixed(0)}min late for ${job_id}`,
);
await notifyManager(job_id, delayMinutes);
}
console.log(
`Technician ${technician_name} arrived at ${customer_name} for ${job_id}`,
);
res.json({ success: true, job_id, status: "in_progress" });
});
// Technician departure endpoint
app.post("/api/technician-departure", async (req, res) => {
const { technician_id, job_site_id, job_id, departure_time, service_type } =
req.body;
// Calculate time-on-site
const visit = await db.jobVisits.findLast({
technician_id,
job_id,
status: "on_site",
});
const timeOnSiteMinutes =
(new Date(departure_time) - new Date(visit.arrival_time)) / 60000;
// Update visit record
await db.jobVisits.update(visit.id, {
departure_time: new Date(departure_time),
time_on_site_minutes: timeOnSiteMinutes,
status: "completed",
});
// Update job to completed
await db.jobs.update(job_id, {
status: "completed",
completed_at: new Date(departure_time),
time_on_site_minutes: timeOnSiteMinutes,
});
// Trigger invoice generation
await invoiceService.generate({
job_id,
service_type,
technician_id,
time_on_site_minutes: timeOnSiteMinutes,
});
console.log(
`Job ${job_id} completed - ${timeOnSiteMinutes.toFixed(0)} minutes on site`,
);
res.json({
success: true,
job_id,
time_on_site_minutes: timeOnSiteMinutes,
status: "completed",
});
});
app.listen(3000, () => console.log("Dispatch backend listening on port 3000"));
Best Practices
1. Service Area Sizing
Size job site geofences to account for GPS drift near buildings:
- Residential: 50-80 meter radius (single-family homes, smaller yards)
- Commercial: 80-150 meter radius (office buildings with parking lots)
- Industrial: 150-300 meter radius (large facilities, campuses)
GPS accuracy degrades near tall buildings. Add a 50-100 meter buffer beyond the actual property boundary to avoid missed triggers.
2. Multi-Stop Routes
For technicians with multiple jobs per day:
- Create geofences for each job site before the technician starts their route
- Use metadata to track the planned stop sequence
- Monitor arrival order against the planned sequence to detect route deviations
- Delete or deactivate geofences after job completion to reduce noise
3. SLA Monitoring
Compare actual arrival times to scheduled times:
const slaCompliance = (scheduledTime, actualArrival) => {
const delayMinutes = (actualArrival - scheduledTime) / 60000;
if (delayMinutes <= 0) return { status: "early", delay: 0 };
if (delayMinutes <= 15) return { status: "on_time", delay: delayMinutes };
if (delayMinutes <= 30) return { status: "late", delay: delayMinutes };
return { status: "sla_breach", delay: delayMinutes };
};
4. Offline Handling for Rural Areas
Technicians may lose connectivity in rural or underground locations:
const locationQueue = [];
async function sendLocation(location) {
try {
await fetch(API_URL, {
method: "POST",
headers: {
"X-API-KEY": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify(location),
});
} catch (error) {
// Queue for later when back online
locationQueue.push({ ...location, queued_at: new Date().toISOString() });
}
}
// Flush queue when connectivity restores
async function flushQueue() {
while (locationQueue.length > 0) {
const location = locationQueue.shift();
try {
await fetch(API_URL, {
method: "POST",
headers: {
"X-API-KEY": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify(location),
});
} catch {
locationQueue.unshift(location);
break; // Still offline, stop retrying
}
}
}
Monitoring and Reporting
List Active Technicians
curl -X GET https://api.spatialflow.io/api/v1/devices \
-H "X-API-KEY: YOUR_API_KEY"
Check Workflow Executions
curl -X GET https://api.spatialflow.io/api/v1/workflows/{workflow_id}/executions?limit=50 \
-H "X-API-KEY: YOUR_API_KEY"
Dashboard KPIs
Track these field service metrics:
- Average response time: Time from job dispatch to technician arrival
- Time-on-site: Average duration per service type (HVAC, plumbing, electrical)
- Jobs per day: Technician throughput and utilization
- SLA compliance: Percentage of jobs where technician arrived within scheduled window
- First-time fix rate: Combine with your job management system to track repeat visits
Troubleshooting
Technician at Job Site but No Trigger
Causes:
- GPS accuracy is poor (common near tall buildings in urban areas)
- Geofence is too small for the location's GPS conditions
- Geofence or workflow is inactive
Solutions:
- Increase geofence radius by 50-100 meters
- Check GPS accuracy in the location update metadata (filter updates with accuracy > 50m)
- Verify
is_active: trueon both the geofence and workflow
Test with point-in-polygon:
curl -X POST https://api.spatialflow.io/api/v1/geofences/test-point \
-H "X-API-KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"geometry": {
"type": "Point",
"coordinates": [-73.9690, 40.7580]
}
}'
Multiple Triggers at Same Location
Cause: GPS signal bounces across geofence boundary, triggering repeated entry/exit events.
Solutions:
- Use dwell time filtering: configure workflows to require 30+ seconds inside the geofence before triggering
- Increase geofence size so the boundary is farther from where the technician is working
- Implement deduplication in your webhook handler (ignore arrival events within 5 minutes of the last one for the same job)
Delayed Notifications
Causes:
- Webhook endpoint is slow to respond (should be < 5 seconds)
- Technician's phone has limited network connectivity
- Workflow execution queue is backed up
Solutions:
- Ensure webhook endpoints return 200 quickly and process work asynchronously
- Check SpatialFlow workflow execution logs for delivery failures
- Verify the technician's device is sending location updates at the expected frequency
Next Steps
- Geofences - Learn about geofence types, properties, and spatial queries
- Workflows - Build complex multi-step automations with conditions
- Devices - Advanced device management and bulk operations
- Webhook Integration - Build robust webhook receivers with retries
- Error Handling - Handle failures and edge cases gracefully
- Signals - Detect anomalies in technician routes and behavior patterns