Data & live sources
Formats and ingest, field inference, time fields, the live source catalog, URL/REST/webhook/MCP connections, refresh policies, query policy and limits.
The library
Data in the workspace rail holds every dataset and every live connection. Datasets are shared by all projects in the workspace; a project references the ones it uses.

- Upload: a file from your disk, up to 50 MB.
- From URL: a GeoJSON, PMTiles, XYZ or TileJSON address, optionally refreshing.
- REST API: an authenticated endpoint: method, headers, query parameters, a records path and a field map.
- MCP source: a tool on your own MCP server becomes a dataset.
- Connections: the live sources behind these datasets, with health and refresh history.
- Filter: matches dataset names as you type.
- Geometry: points, lines, polygons or mixed, detected at ingest.
- Fields: how many attributes survived ingest, each with a type, a role and statistics.
- Serve:
geojsonbelow roughly 5,000 features,tilesabove it. - Status:
LIVEwhen a connection refreshes it,FROZENfor an uploaded file.
Formats
| Source | How | Notes |
|---|---|---|
| GeoJSON | upload or URL | the reference format; URL sources can refresh |
| Shapefile | .zip upload | .shp + .dbf + .prj in one archive; reprojected to WGS84 |
| CSV / XY | upload | longitude and latitude columns, or an address column (geocoded) |
| KML · KMZ | upload | placemarks, folders, styles are dropped in favour of Spatly styling |
| GPX | upload | tracks and waypoints, both ingested |
| PMTiles | upload or URL | served straight from object storage |
| Vector tiles | XYZ or TileJSON URL | a sourceLayer name is required |
| Raster XYZ · WMS · WMTS | URL | drawn as a raster layer, not ingested |
| Feature services (REST) | URL | read-only, paginated |
| REST API | URL + auth | headers, records path, field map |
| Webhook | push | POST /api/webhooks/data/:connectionId |
| MCP source | your own server | a tool result becomes a dataset |
3D coordinates are flattened to 2D on ingest, so a Z value in a GeoJSON point or a GPX track will not stop the load.
Ingest and field inference
GeoJSON, CSV, KML, GPX and Shapefile are parsed in process; everything else goes through GDAL. Geometries land in PostGIS as WGS84 ([lng, lat]).
Every field is then sampled for:
- type:
string,number,integer,boolean,datetime - role:
id,label,category,measure,time,lng,lat - statistics: min, max, distinct count, null count
That inference is what the rest of the product reads: the classification methods propose breaks from min/max and distinct counts, field pickers only offer numeric fields where a number is required, and label fields become the default popup title.
You can correct any of it in the dataset drawer. A code stored as a number is the usual case.
Time fields
A dataset with a datetime field can carry a time field. Setting it turns on:
- time-enabled layers (
layer.timeField) that clip to the beat's time window dateRangeWidgetpresets and the mini timeline brushtimeControlplaybackquery_timeseriesand thetimeseriesoption on metrics
Set it at upload time (options.timeField) or later in the dataset drawer.
Serving: GeoJSON or tiles
| Size | Served as | Endpoint |
|---|---|---|
| ≲ 5,000 features | one GeoJSON document, ETag-cached | GET /api/v1/datasets/:id/geojson?bbox=&limit=&fields= |
| above that | Mapbox Vector Tiles from ST_AsMVT | GET /api/v1/tiles/:datasetId/{z}/{x}/{y} |
The choice is automatic and shows in the Serve column. It matters in one place: on a tiled dataset the client only holds what is in the viewport, so client-side feature counts describe the viewport. Metrics are computed server-side and stay correct either way.
Address geocoding
A CSV with an address column and no coordinates is geocoded at ingest: up to 500 rows, one request per second, against a cached Nominatim proxy. Rows that fail are flagged, not dropped: fix the address in the dataset drawer and re-geocode.
Geocoding is also available directly:
curl -H "Authorization: Bearer $TOKEN" "$BASE/geocode?q=Antakya&limit=1"
curl -H "Authorization: Bearer $TOKEN" "$BASE/reverse?lng=36.16&lat=36.20"
Results are cached for seven days. Attribution (© OpenStreetMap contributors) is required wherever they are shown.
Live connections

- Connections tab: everything that refreshes, separate from the datasets it feeds.
- Kind and target: url, rest, xyz, pmtiles, feature service, wms, webhook or mcp.
- Refresh: interval or cron, and when it last succeeded.
- Health: consecutive failures and the last error, verbatim.
| Kind | What it is |
|---|---|
url | a document Spatly re-fetches: GeoJSON, CSV, KML, GPX |
rest | an authenticated JSON API: method, headers, query params, body template, recordsPath, lngField/latField/idField |
xyz · wms · pmtiles | tile sources; drawn, not ingested |
arcgis_feature | a read-only feature service, paginated |
webhook | you push; Spatly stores |
mcp | a tool on your MCP server, called on the refresh interval |
Secrets (bearer tokens, header values) are encrypted at rest with CONNECTION_SECRET_KEY and never reach a viewer.
Refresh policies
{ "mode": "interval", "intervalSec": 60 }
{ "mode": "schedule", "cron": "*/15 * * * *" }
{ "mode": "webhook" }
{ "mode": "static" }
The minimum interval is 15 s. Sources are fetched server-side and cached, so a thousand viewers cost the upstream one request per interval, not a thousand.
The scheduler runs a tick every 30 s (SPATLY_SCHEDULER=off disables it), takes a lock so only one instance refreshes, runs at most five refreshes in parallel, and evaluates alerts after each one. For a serverless deployment, drive it from an external cron instead:
curl "https://spatly.io/api/internal/cron?key=$CRON_KEY"
Health and stale state
Every connection carries lastSuccessAt, lastErrorAt, lastError, latencyMs and consecutiveFailures. After three consecutive failures the dependent widgets go stale: hatched, with last success N minutes ago. Nothing is silently wrong.
Webhooks
Create a connection of kind webhook and push into its URL. mode=replace (the default) makes the payload the current state; mode=append adds to it.
curl -X POST "https://spatly.io/api/webhooks/data/$CONNECTION_ID?secret=$WEBHOOK_SECRET&mode=replace" \
-H "Content-Type: application/json" \
--data @vehicles.geojson
# → { "received": 128, "featureCount": 128, "mode": "replace" }
The secret can travel as ?secret=, as X-Spatly-Secret, or as a bearer token. Both GeoJSON and a bare array of records are accepted; records need lngField/latField set on the connection.
Live source catalog
One-click public feeds, each with a refresh interval, a suggested style and ready-made metrics:
| Preset | Refresh | Metrics it creates |
|---|---|---|
| USGS earthquakes · past day | 5 min | count · strongest (M) · M4.5+ · by magnitude type |
| USGS earthquakes · past 7 days | 15 min | count · strongest · median depth · M5+ |
| NASA EONET · open natural events | 30 min | open events · largest magnitude |
| NWS active weather alerts (US) | 5 min | active alerts · by event · by severity · extreme + severe |
| OpenSky · aircraft positions | 15 min | aircraft tracked · airborne · average ground speed · by country |
| GDACS · disaster alerts (Orange/Red) | 30 min | active alerts · red alerts · by hazard · by alert level |
Each becomes an ordinary connection you can edit afterwards. OpenSky is rate-limited upstream, so 15 minutes is the floor.
MCP sources
Data → MCP source: paste the server URL (streamable HTTP), add a bearer token if it needs one, press Discover tools (tools/list), pick a tool and its JSON arguments, map the result (recordsPath, lngField/latField, idField), test, save.
Spatly then calls that tool on the refresh interval and turns the result (structuredContent, or the first JSON text block) into a dataset like any other. Layers, metrics and alerts work unchanged. See MCP server for the other direction, where agents read your workspace.
Query policy and the agent budget
Each connection has a query policy:
| Key | Default | Effect |
|---|---|---|
maxLimit | 1000 | the most rows one query may return |
requireBbox | false | refuse a feature query with no bounding box |
rateLimitPerMin | 120 | per connection |
allowedFields | all | a whitelist; other fields are not returned |
agentAccessible | true | when false, Spatly's own MCP server does not expose it |
On top of that, the workspace agent budget (Settings → General) bounds every token: maxCallsPerMin (60), maxRowsPerCall (500), requireBbox. The tighter of the two wins. Lists page with nextCursor; nothing is ever dumped wholesale.
Limits
| Limit | Value |
|---|---|
| Upload size | 50 MB per file |
| Address geocoding at ingest | 500 rows, 1 request/second |
| Minimum refresh interval | 15 s |
| Datasets and storage | per plan, see Plans & limits |
| Live bindings | per plan (2 on Free, 25 on Pro, 200 on Team) |
| Public-id read rate | 600 requests/minute per published surface |
Verified on 2026-09-02 against the local development build (15 seeded datasets across CSV, GeoJSON, KML, GPX, zipped Shapefile and a live URL feed).
Something wrong or missing? Write to hello@spatly.io.