Skip to main content

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​

FieldTypeRequiredDescription
addressstringYesStreet address to geocode (max 500 chars)
namestringNoDisplay name (max 255 chars). Derived from address if omitted.
buffer_metersintNoFence radius in meters (1 to 10000, default 100)
tagsstring[]NoUp to 20 tag strings per item

dedup_strategy​

Controls what happens when a geocoded point falls within an existing geofence's buffer zone:

StrategyBehavior
skip (default)Duplicates return status: "duplicate" with a dedup_match object. Nothing is written. Re-submitting the same batch is idempotent.
overrideDuplicates are created anyway. Results carry dedup_match so you know which were forced. Use this for intentional dual-fencing of the same location.
failFirst 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​

HTTPMeaning
201Batch processed (mixed results possible; check each results[i].status)
401Missing or invalid auth
403Workspace lacks the BULK_IMPORT feature flag
409dedup_strategy: "fail" triggered; partial results in details.results
413Batch exceeds workspace cap (see Batch Size Cap)
422Request body invalid (malformed JSON, missing required fields)
429Rate limit exceeded (see Rate Limits)

Per-item error codes​

CodeCause
GEOCODE_FAILEDGeocoder could not match the address. Check spelling and include city/state/ZIP.
GEOCODER_RATE_LIMITThe geocoder hit an upstream rate limit mid-batch. Retry the failed items after a short delay.
INVALID_TAGSTags failed validation: too long, invalid characters, or more than 20 per item.
CREATE_FAILEDCatch-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 MethodLimit
JWT (browser session)10 requests/hour per workspace
API key60 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