Bulk Create Geofences from Addresses
The bulk-create API is the integration entry point for syncing address-based geofences into SpatialFlow from your existing systems. Each address is geocoded and turned into a circular geofence in a single call — no GeoJSON required.
When to Use This
Use this endpoint when you need to:
- Sync from a CRM or TMS — import customer sites, service zones, or delivery stops from Salesforce, HubSpot, Onfleet, or any flat address list
- Onboard a new customer — turn a customer's address list into geofences in one API call
- Run a scheduled refresh — re-import a CSV nightly;
dedup_strategy: "skip"makes re-imports idempotent (duplicates return a match report, not an error) - One-shot CSV import — a team member pastes addresses into a script and fires a single POST to get all fences created
For interactive imports with a preview step, use the /api/v1/geofences/preview endpoint
before committing.
Authentication
API keys are recommended for integrations. API key calls get a 60 requests/hour limit per workspace (6× the JWT limit), and they don't expire between user sessions.
Generate an API key from Dashboard → Account → API Keys, then include it in the
X-API-Key header:
curl https://api.spatialflow.io/api/v1/geofences/bulk \
-H "X-API-Key: sf_live_..." \
-H "Content-Type: application/json" \
-d '{"items": [{"address": "123 Main St, San Francisco, CA"}]}'
JWT tokens also work. Pass them in the Authorization: Bearer <token> header.
Request Shape
POST /api/v1/geofences/bulk
{
"items": [
{
"address": "123 Main St, San Francisco, CA",
"name": "SF Downtown Hub",
"buffer_meters": 100,
"tags": ["customer:acme", "tier:gold"]
},
{
"address": "456 Market St, San Francisco, CA"
}
],
"dedup_strategy": "skip"
}
Item fields
| Field | Type | Required | Description |
|---|---|---|---|
address | string | Yes | Street address to geocode (max 500 chars) |
name | string | No | Display name (max 255 chars). Derived from address if omitted. |
buffer_meters | int | No | Fence radius in meters (1–10000, default 100) |
tags | string[] | No | Up to 20 tag strings per item |
dedup_strategy
Controls what happens when a geocoded point falls within an existing geofence's buffer zone:
| Strategy | Behavior |
|---|---|
skip (default) | Duplicates return status: "duplicate" with a dedup_match object. Nothing is written. Re-submitting the same batch is idempotent. |
override | Duplicates are created anyway. Results carry dedup_match so you know which were forced. Use this for intentional dual-fencing of the same location. |
fail | First duplicate aborts the entire batch. HTTP 409 is returned with the partial result list. Use this for strict transactional flows. |
Response Shape
All three strategies return the same response envelope. HTTP 201 means the batch
was processed — some items may still have status: "error" or status: "duplicate".
Check each result individually.
{
"results": [
{
"index": 0,
"status": "created",
"geofence_id": "550e8400-e29b-41d4-a716-446655440000"
},
{
"index": 1,
"status": "duplicate",
"dedup_match": {
"geofence_id": "660e8400-e29b-41d4-a716-446655440001",
"name": "Existing SF Hub",
"distance_meters": 12.3
}
},
{
"index": 2,
"status": "error",
"error_code": "GEOCODE_FAILED",
"error": {
"message": "Could not geocode address",
"code": "GEOCODE_FAILED"
}
}
]
}
index is 0-based and matches the position of the item in the request items array.
For error results, error_code is the stable machine-readable key and mirrors
the nested error.code field retained for existing clients.
HTTP status codes
| HTTP | Meaning |
|---|---|
| 201 | Batch processed (mixed results possible — check each results[i].status) |
| 401 | Missing or invalid auth |
| 403 | Workspace lacks the BULK_IMPORT feature flag |
| 409 | dedup_strategy: "fail" triggered — partial results in details.results |
| 413 | Batch exceeds workspace cap (see Batch Size Cap) |
| 422 | Request body invalid (malformed JSON, missing required fields) |
| 429 | Rate limit exceeded (see Rate Limits) |
Per-item error codes
| Code | Cause |
|---|---|
GEOCODE_FAILED | Geocoder could not match the address. Check spelling and include city/state/ZIP. |
GEOCODER_RATE_LIMIT | The geocoder hit an upstream rate limit mid-batch. Retry the failed items after a short delay. |
INVALID_TAGS | Tags failed validation: too long, invalid characters, or more than 20 per item. |
CREATE_FAILED | Catch-all server error during geofence creation (rare). Retry the specific item. |
curl Example
Minimal happy path with two addresses. The first is created, the second is a
near-duplicate that triggers the default skip strategy:
curl -s -X POST https://api.spatialflow.io/api/v1/geofences/bulk \
-H "X-API-Key: sf_live_..." \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"address": "1 Infinite Loop, Cupertino, CA",
"name": "Apple HQ",
"buffer_meters": 200,
"tags": ["customer:apple", "tier:enterprise"]
},
{
"address": "1 Apple Park Way, Cupertino, CA",
"name": "Apple Park"
}
]
}'
Example response:
{
"results": [
{
"index": 0,
"status": "created",
"geofence_id": "550e8400-e29b-41d4-a716-446655440000"
},
{
"index": 1,
"status": "duplicate",
"dedup_match": {
"geofence_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Apple HQ",
"distance_meters": 483.2
}
}
]
}
Python SDK Example
The most common integration pattern is a two-pass import: detect duplicates with
dedup_strategy: "skip", then force-create only the ones you intentionally want to
dual-fence with dedup_strategy: "override".
import asyncio
from spatialflow import SpatialFlow
from spatialflow._generated.spatialflow_generated.models import (
BulkItemInput,
BulkCreateRequest,
)
async def sync_customer_sites(addresses: list[dict]) -> None:
async with SpatialFlow(api_key="sf_live_...") as client:
items = [
BulkItemInput(
address=row["address"],
name=row.get("name"),
tags=row.get("tags", []),
)
for row in addresses
]
# First pass: detect duplicates without writing
first_pass = await client.geofences.bulk_create(
BulkCreateRequest(items=items, dedup_strategy="skip")
)
created = [r for r in first_pass.results if r.status == "created"]
duplicates = [r for r in first_pass.results if r.status == "duplicate"]
errors = [r for r in first_pass.results if r.status == "error"]
print(f"First pass: {len(created)} created, {len(duplicates)} duplicate, {len(errors)} error")
# Log errors — these need address corrections before re-import
for r in errors:
print(f" Error at index {r.index}: {r.error.message} ({r.error.code})")
# Second pass: force-create duplicates the caller explicitly wants to override
# (e.g. a customer opened a second location at the same address)
duplicates_to_force = [items[r.index] for r in duplicates]
if duplicates_to_force:
override_pass = await client.geofences.bulk_create(
BulkCreateRequest(
items=duplicates_to_force,
dedup_strategy="override",
)
)
forced = [r for r in override_pass.results if r.status == "created"]
print(f"Override pass: {len(forced)} force-created")
# Collect all created geofence IDs
all_created_ids = [r.geofence_id for r in first_pass.results if r.status == "created"]
print(f"Total geofences created: {len(all_created_ids)}")
if __name__ == "__main__":
customer_sites = [
{"address": "123 Main St, San Francisco, CA", "tags": ["customer:acme"]},
{"address": "456 Market St, San Francisco, CA", "tags": ["customer:acme"]},
{"address": "789 Mission St, San Francisco, CA", "tags": ["customer:acme"]},
]
asyncio.run(sync_customer_sites(customer_sites))
Install the SDK:
pip install spatialflow
The BulkItemInput and BulkCreateRequest classes are generated from the OpenAPI spec
and available in spatialflow._generated.spatialflow_generated.models. The client.geofences.bulk_create()
method is the typed wrapper in GeofencesResource.
Rate Limits
The /bulk endpoint has dedicated rate limits separate from the general API limits:
| Auth Method | Limit |
|---|---|
| JWT (browser session) | 10 requests/hour per workspace |
| API key | 60 requests/hour per workspace |
With the default batch size cap of 500 items, an API key integration can create up to 30,000 geofences/hour (500 items × 60 requests/hour).
When the rate limit is exceeded, the API returns HTTP 429 with a Retry-After header
indicating when you can retry:
{
"detail": "Rate limit exceeded. Try again in 3412 seconds.",
"error_code": "RATE_LIMIT_EXCEEDED"
}
Batch Size Cap
Each workspace has a configurable batch size limit (default: 500 items, maximum: 1000). Workspace owners can raise this from Dashboard → Workspace Settings → Bulk Create Limit. Raising above 1000 requires SpatialFlow support.
Requests above the cap are rejected with HTTP 413 — no items are processed:
{
"detail": "Batch too large: 501 > 500",
"error_code": "BATCH_TOO_LARGE",
"details": {
"max_items": 500,
"requested_items": 501
}
}
Split large imports into chunks on the client side and send them sequentially or with a short delay between batches to respect rate limits.
Other SDKs
- Node SDK —
client.geofences.appsGeofencesApiBulkCreateGeofences({ bulkCreateRequest: { items, dedup_strategy } })→ See the Node SDK README - Go SDK —
client.BulkCreateGeofences(ctx, spatialflow.BulkCreateRequest{ Items: items, DedupStrategy: "skip" })→ See the Go SDK README
Both SDKs handle authentication, retries, and error mapping automatically.
OpenAPI Spec
The complete schema — including BulkCreateRequest, BulkCreateResponse, BulkResult,
and BulkItemInput definitions — is published at
specs/openapi-latest.json.
Pin to a specific version (e.g., openapi-v1.2.0.json) for stable client generation
with openapi-generator:
openapi-generator generate \
-i https://raw.githubusercontent.com/spatialflow/spatialflow.io/main/specs/openapi-v1.2.0.json \
-g python \
-o ./my-spatialflow-client