Create Your First Geofence
Learn how to create geofences using both the SpatialFlow Dashboard and the API. For a deeper look at geofence types, geometry formats, and advanced options, see Geofences In Depth.
Prerequisites
- A SpatialFlow account (sign up here)
- An API key or JWT token (see Authentication)
- Access to the SpatialFlow Dashboard
Creating a geofence from the dashboard
If you'd rather draw a geofence or create one from an address than call the API, see Geofences in the manager guides. It covers the map editor, the address tab, CSV import, and switching an address-based geofence to a building outline.
Create a Circle via the API
Send the Create Request
curl -X POST https://api.spatialflow.io/api/v1/geofences/ \
-H "X-API-KEY: $SPATIALFLOW_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "NYC Warehouse Zone",
"description": "500m radius around the warehouse entrance",
"geometry": {
"type": "Circle",
"center": [-74.006, 40.714],
"radius_meters": 500
},
"metadata": {
"location": "New York Warehouse"
}
}'
SpatialFlow follows the GeoJSON standard: coordinates are [longitude, latitude], not [latitude, longitude]. Swapping them will place your geofence on the wrong side of the world.
Review the Response
A successful 201 Created response returns the full geofence object:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "NYC Warehouse Zone",
"description": "500m radius around the warehouse entrance",
"geometry": {
"type": "Circle",
"center": [-74.006, 40.714],
"radius_meters": 500.0
},
"geometry_type": "Circle",
"radius_meters": 500.0,
"webhook_url": null,
"webhook_events": [],
"metadata": {
"location": "New York Warehouse"
},
"is_active": true,
"group_id": null,
"group_name": null,
"created_at": "2025-11-09T10:00:00Z",
"updated_at": "2025-11-09T10:00:00Z"
}
Save the id; you'll need it when setting up workflows and testing.
To create a polygon instead, replace the geometry field with a standard GeoJSON Polygon. See the example in the Quick Start overview.
Upload a File (GeoJSON, KML, GPX)
If you already have geofence boundaries in a file, upload it instead of drawing or scripting each one individually. The upload endpoint parses the file and creates one geofence per feature, optionally grouping them together.
Supported formats:
- GeoJSON (
.geojson,.json): standard geospatial format - KML (
.kml): Google Earth format - GPX (
.gpx): GPS track/route format, converted to a padded bounding rectangle (see caution below)
Each GPX track/route is converted to the padded bounding rectangle of all its points (min/max latitude and longitude, plus a small margin), not a buffer that follows the path. A long diagonal track produces a large rectangle covering the whole span between its endpoints, including area the track never passed through. This is usually not what you want for a route-shaped fence. For anything path-shaped, draw or upload the actual polygon as GeoJSON or KML instead; GPX waypoints (<wpt>) are unaffected; those still become small square polygons around each point.
Files are capped at 50 MB, and the upload endpoint is rate-limited to 20 requests per hour, keyed by client IP address, not by user or account. If several users upload from behind the same NAT/office IP, they share that limit.
Uploading is a three-step flow: get a presigned S3 URL, upload the file directly to S3, then hand the file off to the import job. Files go through S3 rather than the API request body directly, so large boundary files don't tie up an API worker.
If you're importing addresses (not boundary files) to create circular geofences around them, use the CSV Bulk Import feature instead; it geocodes each address for you.
The storage endpoints (/storage/presigned-url, /storage/uploads/{file_id}/complete) require storage:write, the import endpoint (/geofences/upload) requires geofences:create, and polling the job status requires geofences:read. Use an API key granted all three (storage:write plus geofences:* is the simplest), or a JWT bearer token; a JWT skips the API-key permission check entirely. A key missing any of them gets a 403 at that step. The examples below use a JWT; swap the Authorization: Bearer header for X-API-KEY if you are using a key.
Authenticate
Log in to get a short-lived JWT access token:
# Serialize with jq so a password containing quotes or backslashes stays valid JSON,
# and send it over stdin so it never appears in the process list.
jq -n --arg email "$SPATIALFLOW_EMAIL" --arg password "$SPATIALFLOW_PASSWORD" \
'{email: $email, password: $password}' |
curl -X POST https://api.spatialflow.io/api/v1/auth/login \
-H "Content-Type: application/json" \
--data-binary @-
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"user": { "id": "...", "email": "you@example.com" }
}
Save access_token as $ACCESS_TOKEN; every request below uses it as a Bearer token.
Step 1: Request a Presigned Upload URL
curl -X POST https://api.spatialflow.io/api/v1/storage/presigned-url \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"file_type": "geofences",
"filename": "warehouse-zones.geojson",
"file_size": 4823
}'
Response:
{
"upload_url": "https://spatialflow-uploads.s3.amazonaws.com/pending/geofences/...",
"key": "pending/geofences/<workspace_id>/<file_id>/20260901120000_warehouse-zones.geojson",
"expires_in": 3600,
"file_type": "geofences",
"filename": "20260901120000_warehouse-zones.geojson",
"file_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"content_type": "application/geo+json",
"required_headers": {
"Content-Type": "application/geo+json",
"Content-Length": "4823",
"If-None-Match": "*"
}
}
Save both file_id (Step 3) and upload_url (Step 2), for example:
export FILE_ID="f47ac10b-58cc-4372-a567-0e02b2c3d479"
export UPLOAD_URL="https://spatialflow-uploads.s3.amazonaws.com/pending/geofences/..."
Step 2: Upload the File to S3
PUT the raw file bytes to upload_url, sending exactly the headers from required_headers:
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/geo+json" \
-H "Content-Length: 4823" \
-H "If-None-Match: *" \
--data-binary @warehouse-zones.geojson
Step 3: Complete the Upload
This verifies the object actually landed in S3 (size and content type) before it's usable:
curl -X POST "https://api.spatialflow.io/api/v1/storage/uploads/$FILE_ID/complete" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response:
{
"file_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"status": "complete",
"size_bytes": 4823,
"content_type": "application/geo+json"
}
Step 4: Start the Import Job
curl -X POST https://api.spatialflow.io/api/v1/geofences/upload \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"file_id\": \"$FILE_ID\", \"group_name\": \"Warehouse Zones\"}"
group_name is optional; pass it to assign every imported geofence to a group.
Response (202 Accepted):
{
"job_id": "8f14e45f-ceea-4b19-8e97-9c7c9e8a4f1d",
"status": "pending",
"message": "Upload job queued for processing. File: warehouse-zones.geojson",
"status_url": "https://api.spatialflow.io/api/v1/geofences/upload/8f14e45f-ceea-4b19-8e97-9c7c9e8a4f1d/status"
}
Import runs asynchronously in the background; poll the status endpoint to track progress.
Save the returned job_id:
export JOB_ID="8f14e45f-ceea-4b19-8e97-9c7c9e8a4f1d"
Step 5: Poll the Job Status
curl "https://api.spatialflow.io/api/v1/geofences/upload/$JOB_ID/status" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response (once completed):
{
"job_id": "8f14e45f-ceea-4b19-8e97-9c7c9e8a4f1d",
"status": "completed",
"file_name": "warehouse-zones.geojson",
"file_format": "geojson",
"created_at": "2026-09-01T12:00:00Z",
"started_at": "2026-09-01T12:00:01Z",
"completed_at": "2026-09-01T12:00:04Z",
"duration": 3.2,
"total_features": 5,
"created_count": 5,
"failed_count": 0,
"results": {
"created_geofences": [
{ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Zone A" }
],
"errors": []
},
"error_message": null
}
status moves through pending → processing → completed (or failed). Check failed_count and results.errors for any features that couldn't be imported; a partial import (some created, some failed) still reports completed.
failed_count coversfailed_count counts both kinds of failure: features the parser rejected (not a Polygon, malformed geometry, a GPX track with too few points) and features that parsed but failed while being created (e.g. hit your geofence limit). Every one of them appears in results.errors with its feature index, name and reason, and total_features is the number of features the file contained, so created_count + failed_count == total_features. If nothing could be created at all - every feature rejected, or the batch exceeding your geofence limit - the job ends as failed rather than completed, but it still carries those counts and reasons.
Using the Python SDK
The Python SDK (pip install spatialflow) exposes an upload_geofences() helper that runs all five steps above, including polling, in one call. Construct the client with api_key= or with access_token= and the JWT from the login call. The helper polls the status endpoint, so a key needs storage:write, geofences:create and geofences:read:
import asyncio
import os
from spatialflow import SpatialFlow, upload_geofences
# The JWT from POST /api/v1/auth/login, exported as $ACCESS_TOKEN in the curl example above
ACCESS_TOKEN = os.environ["ACCESS_TOKEN"]
async def main():
async with SpatialFlow(access_token=ACCESS_TOKEN) as client:
result = await upload_geofences(
client,
"warehouse-zones.geojson",
group_name="Warehouse Zones",
timeout=120,
)
print(f"Created {result.created_count} geofences")
asyncio.run(main())
Error Responses
Errors follow the standard {"detail": "..."} format:
| Status | Cause |
|---|---|
400 | File not found or access denied, upload not yet verified, invalid file type (only geofences file type is accepted), or unsupported file format |
401 | Missing or invalid API key / JWT token |
429 | Rate limit exceeded (20 uploads/hour per client IP) |
500 | Failed to queue the upload job |
Verify Your Geofence
Test a Point Against Your Geofences
Use the test-point endpoint to check whether a coordinate falls inside any of your active geofences:
curl -X POST https://api.spatialflow.io/api/v1/geofences/test-point \
-H "X-API-KEY: $SPATIALFLOW_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"geometry": {
"type": "Point",
"coordinates": [-74.006, 40.714]
}
}'
Response:
{
"point": {
"type": "Point",
"coordinates": [-74.006, 40.714]
},
"inside_geofences": 1,
"total_geofences": 3,
"results": [
{
"geofence_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"geofence_name": "NYC Warehouse Zone",
"is_inside": true,
"distance_meters": 0
},
{
"geofence_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"geofence_name": "Downtown Delivery Zone",
"is_inside": false,
"distance_meters": 1250.5
}
],
"matched_geofences": [
{
"geofence_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"geofence_name": "NYC Warehouse Zone",
"group_id": null,
"group_name": null,
"distance_meters": 0
}
]
}
resultslists every active geofence with itsis_insidestatus and distance from the pointmatched_geofenceslists only the geofences that contain the point, with group information included
The endpoint answers the way entry detection would: a point exactly on a polygon's edge counts as inside, small shapes get the same minimum trigger radius, and a geofence outside its schedule window reports is_inside: false.
The test sends no webhooks and creates no events, however often you repeat it. An API key needs the geofences:read permission and can't be read-only, because a read-only key can only make GET requests. To send a test webhook delivery for a geofence, use POST /api/v1/geofences/{geofence_id}/test-event, which marks what it sends as a test.
The test-point endpoint checks the point against all of your active geofences. To test against specific geofences only, include a geofence_ids array in your request body.
You've successfully created and verified a geofence:
- Created a geofence via the dashboard or API
- Tested a point against your geofences
- Confirmed your geofence is working
Next Steps
Ready to put your geofences to work? Continue with these guides:
- Create Your First Workflow - Trigger actions when devices enter or exit your geofences
- Geofences In Depth - Polygons, circles, groups, and advanced options
- Workflows - Build multi-step automations with conditional logic
- Webhooks - Configure delivery, retries, and authentication
- API Reference - Explore the full API documentation