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 Settings > 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_..." \
-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. An API key needs the geofences:create permission.
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 to 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_..." \
-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_...") 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 batch size limit (default 500 items, ceiling 1000), stored per workspace but with no dashboard control to change it yet. Email support@spatialflow.io to raise it within the 500-to-1000 range; going above 1000 needs SpatialFlow ops to intervene directly.
Requests above the cap are rejected with HTTP 413, and 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 spatialflow-node repository. - Go SDK:
client.BulkCreateGeofences(ctx, spatialflow.BulkCreateRequest{ Items: items, DedupStrategy: "skip" }). See the spatialflow-go repository.
Both SDKs handle authentication and error mapping. Neither retries a bulk create: the Go client retries only idempotent methods, and the Node client doesn't retry. Retry a 429 yourself after its Retry-After delay, and a 5xx with backoff. With dedup_strategy: "skip", resubmitting the same batch is idempotent.
OpenAPI Spec
The complete schema, including the BulkCreateRequest, BulkCreateResponse, BulkResult
and BulkItemInput definitions, is in the OpenAPI document
that this reference is built from. To generate a client, save a copy of that file with your
code, so a later API release doesn't change your client unexpectedly:
curl -o spatialflow-openapi.json https://docs.spatialflow.io/openapi/openapi.json
openapi-generator generate \
-i spatialflow-openapi.json \
-g python \
-o ./my-spatialflow-client