Importing locations with GeoJSON
Create and update up to 1,000 locations in one request by sending a GeoJSON FeatureCollection. Points become circles, polygons become polygons, and an external_id lets you re-import without making copies.
POST /locations/import takes a GeoJSON FeatureCollection and creates or updates a location for each feature, straight away, in one call. It needs a token with the locations:write ability. It's the only way to create a location through the API, and the only way to change a location's shape.
The request#
The body is a FeatureCollection with between 1 and 1,000 features:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-0.1243, 51.5117] },
"properties": { "name": "Covent Garden store", "radius": 150, "external_id": "store-0142", "group": "London stores" }
}
]
}Each feature's geometry sets the shape, and its properties set the rest:
| Property | Required | What it does |
|---|---|---|
name | Yes | The location's name, up to 255 characters. |
radius | For a Point | The circle's radius in metres, from 50 to 1,000. |
external_id | No | Your own id for the location, up to 255 characters. If a location with this external_id already exists, the feature updates it; otherwise it creates one. |
group | No | The name of a location group, up to 255 characters. The location is added to the group, and the group is created if it doesn't exist. |
Other properties are ignored.
Shapes#
- Point plus
properties.radiusbecomes a circle centred on the point. Coordinates are[longitude, latitude], as GeoJSON always has them. - Polygon becomes a polygon. Its outer ring needs 3 to 99 points (not counting the closing point, which repeats the first), in
[longitude, latitude]order, and it can't cross over itself.
Any other geometry, such as a MultiPolygon or a LineString, fails.
Creating and updating#
- A feature with an
external_idupdates the location that has thatexternal_id, if there is one: its name and shape are replaced with the feature's. If there isn't one, it creates a location with thatexternal_id. - A feature without an
external_idalways creates a new location. Importing the same file twice makes two copies, so give every feature anexternal_idif you'll import it again. grouponly ever adds: re-importing a location with a differentgroupadds it to that group as well, and doesn't take it out of the old one.
To keep Bubbl in step with a list you maintain, such as your store list, use each store's id from your own system as its external_id and re-import the whole list whenever it changes.
Plan limits#
Each new location counts towards your plan's location limit, in the order the features are given. Once the limit is reached, the remaining new locations are skipped. Updates never count towards the limit. The Sandbox has its own limit (see Sandbox and Production).
The response#
The response has a result for every feature, in the same order, and a summary:
| Field | What it is |
|---|---|
index | The feature's position in features, from 0. |
status | created, updated, skipped or failed. |
name | The location's name, or null if the feature had none. |
id | The location's id. null for skipped and failed, and for created in a dry run. |
reasons | Why a feature was skipped or failed, as a list of messages. null otherwise. |
| Status | What happened |
|---|---|
created | A new location was created. |
updated | The location with this external_id was updated. |
skipped | The feature was valid, but your plan's location limit was reached. |
failed | The feature isn't valid. Nothing was written for it. |
Each feature stands on its own: one that fails doesn't stop the others. The response is 200 even when some features fail, so check summary.failed and summary.skipped. The whole request is refused with 422 validation_failed only when the envelope is wrong: type isn't FeatureCollection, or features is missing, empty or longer than 1,000.
Reasons a feature fails include:
Name is missing (properties.name)A Point needs properties.radius (in metres)radius must be between 50 m and 1,000 mA Point needs [longitude, latitude] coordinatesCoordinates are out of rangeThe shape needs 3 to 99 pointsThe shape crosses over itselfThe geometry must be a Point (with properties.radius) or a Polygon
Dry run#
Add ?dry_run=1 to the URL, or "dry_run": true to the body, to check a file without writing anything. You get the same results and summary, with "dry_run": true, so you can fix failed features before the real import. In a dry run, created results have no id yet; updated ones have the id of the location they'd update.
A dry run counts its own new locations towards your plan's limit as it goes, so near the limit it reports skipped for the same features the real import would skip.
If you use an Idempotency-Key, use a different one for the dry run and the real import.
Full example#
This file has four features: a new store (a circle), a car park that's already in Bubbl (a polygon, matched by its external_id), a store with no name, and a store with a radius that's too big.
curl https://api.bubbl.tech/platform/v1/locations/import \
-H "Authorization: Bearer $BUBBL_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7b2e4c90-1f3a-4d5e-8a6b-9c0d1e2f3a4b" \
-d @- <<'EOF'
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-0.1243, 51.5117] },
"properties": { "name": "Covent Garden store", "radius": 150, "external_id": "store-0142", "group": "London stores" }
},
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[[-0.1255, 51.5308], [-0.1226, 51.5308], [-0.1226, 51.5292], [-0.1255, 51.5292], [-0.1255, 51.5308]]]
},
"properties": { "name": "King's Cross car park", "external_id": "car-park-07", "group": "London stores" }
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-1.5491, 53.7997] },
"properties": { "radius": 200, "external_id": "store-0231" }
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-2.2426, 53.4808] },
"properties": { "name": "Manchester Arndale", "radius": 2000, "external_id": "store-0305" }
}
]
}
EOFimport { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
const featureCollection = JSON.parse(await readFile('stores.geojson', 'utf8'));
const response = await fetch('https://api.bubbl.tech/platform/v1/locations/import', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}`,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify(featureCollection),
});
const { data } = await response.json();
console.log(data.summary);
for (const result of data.results.filter((r) => r.status === 'failed' || r.status === 'skipped')) {
console.warn(`Feature ${result.index} (${result.name ?? 'no name'}): ${result.reasons.join('; ')}`);
}import json
import os
import uuid
import requests
with open("stores.geojson") as f:
feature_collection = json.load(f)
response = requests.post(
"https://api.bubbl.tech/platform/v1/locations/import",
headers={
"Authorization": f"Bearer {os.environ['BUBBL_API_TOKEN']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json=feature_collection,
timeout=60,
)
response.raise_for_status()
data = response.json()["data"]
print(data["summary"])
for result in data["results"]:
if result["status"] in ("failed", "skipped"):
print(f"Feature {result['index']} ({result['name'] or 'no name'}): {'; '.join(result['reasons'])}")The response:
{
"data": {
"dry_run": false,
"results": [
{ "index": 0, "status": "created", "name": "Covent Garden store", "id": "01926f3b-0c4d-7e5f-8a6b-7c8d9e0f1a2b", "reasons": null },
{ "index": 1, "status": "updated", "name": "King's Cross car park", "id": "01925e11-4b5c-7d6e-9f0a-1b2c3d4e5f60", "reasons": null },
{ "index": 2, "status": "failed", "name": null, "id": null, "reasons": ["Name is missing (properties.name)"] },
{ "index": 3, "status": "failed", "name": "Manchester Arndale", "id": null, "reasons": ["radius must be between 50 m and 1,000 m"] }
],
"summary": { "created": 1, "updated": 1, "skipped": 0, "failed": 2 }
}
}Fix the two failed features and import the file again with a new Idempotency-Key. The first two features update the same locations rather than making copies, because they have an external_id.
After the import#
GET /locations/{id} returns a location with its shape as GeoJSON, the same way you sent it: a Point with radius_meters for a circle, or a Polygon.
{
"data": {
"id": "01926f3b-0c4d-7e5f-8a6b-7c8d9e0f1a2b",
"external_id": "store-0142",
"name": "Covent Garden store",
"address": null,
"type": "circle",
"geometry": { "type": "Point", "coordinates": [-0.1243, 51.5117] },
"radius_meters": 150,
"groups": ["London stores"],
"created_at": "2026-09-28T10:02:11+00:00",
"updated_at": "2026-09-28T10:02:11+00:00"
}
}PATCH /locations/{id} changes a location's name, address or external_id, but not its shape: to move or resize a location, import it again with its external_id. To give an existing location an external_id so you can re-import it, set one with PATCH first. DELETE /locations/{id} deletes a location.