ChangelogEdit pageSTAFFOpen dashboard

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:

STORES.GEOJSON
{
  "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:

PropertyRequiredWhat it does
nameYesThe location's name, up to 255 characters.
radiusFor a PointThe circle's radius in metres, from 50 to 1,000.
external_idNoYour 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.
groupNoThe 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.radius becomes 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_id updates the location that has that external_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 that external_id.
  • A feature without an external_id always creates a new location. Importing the same file twice makes two copies, so give every feature an external_id if you'll import it again.
  • group only ever adds: re-importing a location with a different group adds 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:

FieldWhat it is
indexThe feature's position in features, from 0.
statuscreated, updated, skipped or failed.
nameThe location's name, or null if the feature had none.
idThe location's id. null for skipped and failed, and for created in a dry run.
reasonsWhy a feature was skipped or failed, as a list of messages. null otherwise.
StatusWhat happened
createdA new location was created.
updatedThe location with this external_id was updated.
skippedThe feature was valid, but your plan's location limit was reached.
failedThe 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 m
  • A Point needs [longitude, latitude] coordinates
  • Coordinates are out of range
  • The shape needs 3 to 99 points
  • The shape crosses over itself
  • The 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
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" }
    }
  ]
}
EOF
JAVASCRIPT
import { 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('; ')}`);
}
PYTHON
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:

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.

RESPONSE
{
  "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.