Alpha — GeoCanvas is in early development. Expect rough edges and breaking changes.
Developer tools

GeoCanvas Python SDK

A Pythonic wrapper around the GeoCanvas REST API — create maps, upload geospatial data, manage layers, and run analytics queries from scripts, notebooks, and pipelines.

Status — planned / target design

The geocanvas Python package is not yet published to PyPI. This page documents the intended SDK surface and pairs every method with the real, shipping HTTP endpoint it calls, so the JSON shapes and routes below are accurate today even before the package exists. Until it ships you can call the same endpoints directly with requests — the “Plain requests” example at the bottom works against a running server right now.

Getting started

Installation

Once published, install from PyPI. The SDK targets Python 3.9+ and depends on requests and pydantic.

bash
# Target install (after the package is published)
pip install geocanvas

# Optional extras for GeoDataFrame integration
pip install "geocanvas[geo]"

Until then, install requests and call the API directly (see the last example): pip install requests.

Authentication

Authentication

GeoCanvas signs users in with emailed magic links (POST /auth/request → click the link → a gc_session cookie) — passwordless, so there is no credential a script can POST. Crucially, the data routes (/maps, /layers, /upload-layer, /analytics/query) do not currently enforce auth, so on a local or self-hosted instance you can call them without a session.

There is no API-key mechanism yet. The realistic path the SDK will follow once API keys land:

  • Mint a key in the dashboard, send it as Authorization: Bearer <key> on every request.
  • The client will read it from the GEOCANVAS_TOKEN env var or a token= constructor argument.
python
from geocanvas import GeoCanvasClient

# Local dev: no token needed yet (data routes are currently open).
client = GeoCanvasClient("http://localhost:4001")

# Target shape once API keys are enforced — sent as Bearer on every call:
# client = GeoCanvasClient("http://localhost:4001", token="...")

Quickstart

Quickstart

Create a client, make a map, and list what is on the server. Maps are stored in PostgreSQL; layers reference tiled data served by the C++ backend.

python
from geocanvas import GeoCanvasClient

client = GeoCanvasClient("http://localhost:4001")

# Create a map (only "name" is required by POST /maps).
m = client.create_map("My First Map")
print(m.id, m.name)          # -> "5af5..." "My First Map"

# List all maps (GET /maps returns lightweight rows + layer_count).
for row in client.list_maps():
    print(row.id, row.name, row.layer_count)

Reference

Core API

Every SDK method maps to one shipping endpoint. The table is the contract; the JSON shapes below it are exactly what the server reads and returns.

SDK methodHTTPNotes
list_maps()GET /mapsArray of {id, name, slug, created_at, layer_count}
create_map(name, ...)POST /mapsBody needs name; returns {id, name, created_at}
get_map(id)GET /maps/:idFull map incl. layers_json, view_state
update_map(id, ...)PUT /maps/:idname is required; sends the full layers_json
delete_map(id)DELETE /maps/:id404 if missing
list_layers()GET /layersThe ingested-data catalog (uploaded tile layers)
upload_layer(geojson, key=...)POST /upload-layerETL → PMTiles + analytics parquet
query(sql, params=...)POST /analytics/querySELECT/WITH only (DuckDB)

Map JSON shape

GET /maps/:id returns the full record. POST /maps (create) and PUT /maps/:id (update) read these fields from the body — note that update is a full replace of layers_json, and the server requires name on every update.

json
{
  "id": "5af53d5a-dacd-444f-a158-b979aacdb89c",
  "name": "US Demographics 2024",
  "slug": "us-demographics-2024",
  "created_at": "2026-06-27T10:00:00.000Z",
  "layers_json": [ /* array of layer configs, see below */ ],
  "view_state": { "longitude": -98.5, "latitude": 39.8, "zoom": 4 },
  "charts_json": [],
  "is_public": false
}

Layer config shape

Layers live inside a map's layers_json array. Configs are immutable by convention — to change one, replace it. A tile basemap and an mvt layer pointing at an uploaded dataset are the two common cases:

json
[
  {
    "id": "lyr-basemap",
    "type": "tile",
    "name": "Carto Light",
    "url": "/proxy/tiles/carto-light/{z}/{x}/{y}",
    "tileSize": 256,
    "visible": true
  },
  {
    "id": "lyr-cities",
    "type": "mvt",
    "name": "Cities CA",
    "url": "/tiles/cities-ca/{z}/{x}/{y}.pbf",
    "fillColor": [40, 120, 200, 180],
    "lineColor": [30, 60, 120, 220],
    "lineWidth": 1,
    "visible": true
  }
]

The server stores layers_json verbatim (it is rendered client-side by deck.gl), so any field deck.gl understands is allowed. Tile URLs for uploaded data are always /tiles/<key>/{z}/{x}/{y}.pbf.

Data ingest

Uploading data

The real ingest endpoint is POST /upload-layer (the older /upload-geojson from early drafts no longer exists). The request body is the raw file; the target layer key and source format are query parameters:

text
POST /upload-layer?key=cities-ca&format=geojson
Content-Type: application/geo+json
<...raw GeoJSON bytes...>

Supported format values: geojson, shapefile (a single .zip), geopackage, csv (auto-detects lon/lat or a WKT column; geocodes addresses when present), and geoparquet. The key must be 1–80 chars of [a-z0-9_-]. The server runs tippecanoe to produce PMTiles, builds a companion analytics parquet, and registers the layer. The response:

json
{
  "key": "cities-ca",
  "feature_count": 1,
  "id_property": "name",
  "extent": { "type": "Polygon", "coordinates": [ /* bbox ring */ ] }
}

Note: /upload-layer needs S3 + DuckDB configured on the server (it returns 503 otherwise). Tiles for the new layer are then served at /tiles/cities-ca/{z}/{x}/{y}.pbf, ready to drop into a map's layers_json as an mvt layer.

Examples

Copy-paste examples

1 — Create a map with layers

python
from geocanvas import GeoCanvasClient, LayerConfig, ViewState

client = GeoCanvasClient("http://localhost:4001")

basemap = LayerConfig(
    id="lyr-basemap",
    name="Carto Light",
    type="tile",
    url="/proxy/tiles/carto-light/{z}/{x}/{y}",
    tileSize=256,
)

cities = LayerConfig(
    id="lyr-cities",
    name="Cities CA",
    type="mvt",
    url="/tiles/cities-ca/{z}/{x}/{y}.pbf",
    fillColor=[40, 120, 200, 180],
    lineColor=[30, 60, 120, 220],
    lineWidth=1,
)

m = client.create_map(
    name="US Demographics 2024",
    layers=[basemap, cities],
    view_state=ViewState(longitude=-98.5, latitude=39.8, zoom=4),
)
print("Created:", m.id)

2 — Upload GeoJSON, then add it to a map

python
geojson = {
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "geometry": {"type": "Point", "coordinates": [-122.4194, 37.7749]},
            "properties": {"name": "San Francisco", "population": 873965},
        }
    ],
}

# POST /upload-layer?key=cities-ca&format=geojson
result = client.upload_layer(geojson, key="cities-ca", format="geojson")
print(f"Ingested {result.feature_count} features as '{result.key}'")

# Add the freshly-tiled layer to a map (full layers_json replace).
m = client.create_map("CA Cities")
client.add_layer(m.id, LayerConfig(
    id="lyr-cities",
    name="Cities CA",
    type="mvt",
    url=f"/tiles/{result.key}/{{z}}/{{x}}/{{y}}.pbf",
))

3 — Run an analytics query (DuckDB)

/analytics/query accepts SELECT/WITH only, with bound parameters. Reads the uploaded layer's analytics parquet via read_parquet:

python
res = client.query(
    "SELECT name, population FROM read_parquet($1) ORDER BY population DESC LIMIT 5",
    params=["s3://geocanvas/analytics/cities-ca.parquet"],
)
for row in zip(*[iter(res["rows"])]):
    print(row)
# res = {"columns": [...], "rows": [[...], ...], "row_count": N, "truncated": false}

Plain requests (works today, no SDK needed)

Because the SDK is just a thin wrapper, you can drive the same endpoints right now. This script runs end-to-end against a local server:

python
import requests

BASE = "http://localhost:4001"

# Create a map.
m = requests.post(f"{BASE}/maps", json={"name": "Made via requests"}).json()
map_id = m["id"]
print("created", map_id)

# Add a basemap layer (PUT replaces the whole map; name is required).
requests.put(f"{BASE}/maps/{map_id}", json={
    "name": "Made via requests",
    "layers_json": [{
        "id": "lyr-basemap", "type": "tile", "name": "Carto Light",
        "url": "/proxy/tiles/carto-light/{z}/{x}/{y}", "tileSize": 256,
    }],
    "view_state": {"longitude": -98.5, "latitude": 39.8, "zoom": 4},
})

# Read it back.
full = requests.get(f"{BASE}/maps/{map_id}").json()
print("layers:", len(full["layers_json"]))

# List the data catalog (uploaded tile layers).
print("catalog:", [l["key"] for l in requests.get(f"{BASE}/layers").json()])

Gotchas

Gotchas

  • Update is a full replace. PUT /maps/:id overwrites layers_json wholesale and requires a non-empty name — there is no partial PATCH. Fetch the map, modify the list, send it all back. The SDK's add_layer() / remove_layer() do exactly this read-modify-write.
  • Map IDs are UUIDs. The :id routes 400 on anything that is not a valid UUID before they touch the database.
  • Create returns a slim object. POST /maps responds with only {id, name, created_at} (no slug/layers_json); call get_map(id) for the full record.
  • Slugs are unique. Setting a duplicate slug on update returns 409 Conflict.
  • Analytics is read-only. /analytics/query rejects anything but SELECT/WITH, blocks stacked statements, and denylists file-reading functions. Use bound params, not string interpolation.
  • Upload needs the full stack. /upload-layer returns 503 unless S3 and DuckDB are configured; it forks tippecanoe and is serialized one at a time, so large/dense uploads can be slow.
  • Auth is not enforced yet. Do not rely on the mock token for access control. Treat any reachable instance as open until JWT support ships.