Skip to main content

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​

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"
}
}'
Coordinate Order

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.

Creating a Polygon via the API

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)
GPX tracks become a bounding rectangle, not a route buffer

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.

Address-based bulk import

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.

Permissions this flow needs

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.

What failed_count covers

failed_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:

StatusCause
400File not found or access denied, upload not yet verified, invalid file type (only geofences file type is accepted), or unsupported file format
401Missing or invalid API key / JWT token
429Rate limit exceeded (20 uploads/hour per client IP)
500Failed 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
}
]
}
  • results lists every active geofence with its is_inside status and distance from the point
  • matched_geofences lists 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.

warning

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: