Skip to content
getgeolens.com

API Reference

The GeoLens REST API exposes the full catalog, search, map, and standards endpoints. This reference is auto-generated from a committed OpenAPI snapshot of a running GeoLens instance. Every endpoint, parameter, and response schema is derived directly from the FastAPI application.

Authentication

JWT bearer tokens, API keys (header and query forms), and OAuth/OIDC. Start here if your client is making its first request.

Authentication ->

OGC & Standards Endpoints

OGC API Common, Records, Features, STAC 1.0, and tile endpoints with QGIS, GDAL, and pystac-client examples.

OGC Endpoints ->

Endpoints by Tag

Browse the full reference grouped by FastAPI tag. Each operation includes parameters, request body, and response schemas. The sidebar lists all 25 tag groups, and the index at the bottom of this page names every operation so you can search for one by method and path.

Endpoint index ->

QGIS

Add a GeoLens dataset to QGIS over OGC API Features, push CQL2 filters down to the server, and paste vector-tile URLs directly.

Use GeoLens from QGIS ->

Client SDKs

Prefer typed code over raw HTTP? The Python and TypeScript SDKs are auto-generated from this same contract.

Client SDKs ->

Ingest & jobs

Writing to the catalog: upload, preview, commit, then poll the job. Real endpoint paths and the 202-then-poll contract.

Ingest sequence ->

Errors & rate limits

The RFC 7807 body every failure returns, and what a 429 looks like when you have asked for too much too fast.

Error responses ->

Rate limits ->

Runnable examples

Single-file pages and scripts that run against the public demo: MapLibre, Leaflet, OpenLayers, ArcGIS JS, QGIS, STAC, DuckDB, GeoPandas, the SDKs, the CLI, and MCP. CI replays them against the demo on every change.

Examples gallery ->

Reading the catalog is one half of the API; the ingest lane is the other. The write endpoints under /ingest require a credential carrying the upload capability; GET /ingest/upload/config needs only an authenticated user, and GET /jobs/{job_id} is limited to the job’s owner (or an admin). A read_only API key authenticates GET, HEAD, and OPTIONS only, so it cannot drive this sequence — use a full key or a JWT (see Authentication). Paths below are relative to the API base, http://localhost:8080/api on a default install.

1. Ask what the instance accepts. GET /ingest/upload/config returns allowed_extensions, max_file_size_bytes, presigned_uploads, and presigned_threshold_bytes. The extension list is an admin setting (upload_allowed_extensions, Admin -> Storage); the shipped default is .zip, .gpkg, .geojson, .json, .csv, .tif, .tiff, .xlsx, .xls, .parquet, .fgb, .kml, .kmz, .3tz, .laz. Standalone .vrt uploads are rejected at every upload door — a VRT is assembled from raster datasets already in the catalog, via POST /ingest/vrt/create. Create and add-source answer 422 when the VRT would reach people who cannot read one of its members, which the VRT’s owner does not also own.

2. Send the bytes. POST /ingest/upload as multipart/form-data with a file part returns 201 and a job_id. Nothing is queued yet.

Add a kind field for the 3D types: tiles3d for a 3D Tiles .zip or .3tz archive, pointcloud for a COPC .laz. Without it a .3tz or .laz is refused, and a tileset .zip is read as ordinary geospatial data. Omit kind for every other file. See 3D Tiles and point clouds.

On an instance backed by S3 storage (presigned_uploads: true in the config response), the payload can skip the API entirely: POST /ingest/upload/presigned with {"filename", "file_size", "content_type"} (plus kind for the 3D types) returns 201 with a job_id and one or more presigned urls. PUT the bytes yourself — a single URL below presigned_threshold_bytes, part_size-sized parts above it — then POST /ingest/upload/presigned/{job_id}/complete, passing {"parts": [{"etag", "part_number"}]} for the multipart case.

3. Preview. POST /ingest/preview/{job_id}, callable only while the job is pending. Vector sources come back with columns, CRS, geometry type, feature count, sample rows, and the layer list; rasters with band count, CRS, resolution, and COG compliance. Add ?layer_name= to inspect one layer of a multi-layer source.

4. Commit. POST /ingest/commit/{job_id} with a body of at least {"title": "..."} returns 202 Accepted and queues the ingest task. For a multi-layer source, POST /ingest/commit-fan-out/{job_id} turns each requested layer into its own dataset — run the preview first, because the endpoint validates every layer_name against the layer list the preview recorded.

5. Poll the job. GET /jobs/{job_id} reports status — one of pending, running, complete, failed, cancelled, fanned_out — plus progress (0 to 1), current_step, error_message, and dataset_id once the dataset exists. cancelled means the job was cancelled with POST /jobs/{job_id}/cancel, or it was an upload nobody committed or completed, which the stale-job sweep settles as cancelled with the reason upload_abandoned. failed means work was attempted and broke.

Already have the data in PostGIS? GET /ingest/discover/ lists tables that aren’t in the catalog yet, and POST /ingest/register/ catalogs one in place without moving any bytes. These two are admin-only: they need the manage_settings permission rather than upload, so an editor gets 403.

Request and response schemas for all of these live in the generated reference: the ingest operations under the Datasets tag, the job endpoints under the Admin tag.

POST /query/ runs one SELECT through a read-only sandbox and returns {columns, rows, row_count, truncated}. It is a POST that is semantically a read, so a read_only API key is accepted; the caller must be authenticated either way and hold the use_ai_chat permission, which editors and admins have by default and viewers do not.

restrict_tables is required and non-empty. It names every data.* table the statement may touch, without the data. prefix, and it can only narrow what your credential already sees — it never widens it. A dataset’s table name is the table_name field of GET /datasets/{dataset_id}.

Terminal window
curl -sS -X POST "$GEOLENS_URL/api/query/" \
-H "X-Api-Key: $GEOLENS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"sql": "SELECT name, pop_est FROM data.world_countries_natural_earth_1_50m LIMIT 5",
"restrict_tables": ["world_countries_natural_earth_1_50m"],
"row_limit": 5}'

The sandbox refuses anything that is not a single SELECT, along with writes, other schemas, and functions outside its allowlist. Each query runs under a 5-second statement timeout and a row_limit of at most 1000 (default 100), and one caller runs one query at a time. Rejections come back as 422 (invalid or too expensive), 404 (a table you named that you cannot see — denial and non-existence deliberately share one answer), or 429 (your previous query is still running, or the instance is at its concurrency ceiling). The full budget — the self-join cap, the blocked output-amplifying functions, the response-size ceiling — is written out under Using query, because the MCP server’s query tool is the same endpoint.

Errors raised by the application answer as RFC 7807 problem details: Content-Type: application/problem+json, with a four-field body.

{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Dataset not found"
}

All four fields are always present. type is about:blank on every error — GeoLens does not mint per-error type URIs, so branch on status, never on type. title is a short label, normally the reason phrase for that status (Validation Error on a 422). detail is normally a string; a validation failure names the offending parameter (query.limit: Input should be a valid integer), and a handful of endpoints put an object or an array there instead, so parse it defensively rather than assuming a string. A refused workflow transition, for one, answers 422 with an object carrying code, message, current_stage, requested_stage and allowed_stages.

The standards surface — /, /conformance, /collections*, /stac* — uses the same envelope with one difference: OGC API Common requires malformed parameters to be reported as 400, where the native routes report 422. Same body, different number. A client that treats 422 as “I sent something bad” should treat 400 the same way.

One failure skips the envelope. A request that matches no route at all, or uses a method a matched path does not answer, is rejected by the framework before any handler and comes back as plain application/json — {"detail": "Not Found"} with a 404, {"detail": "Method Not Allowed"} with a 405.

Responses carry an X-Request-ID. A 500 body is deliberately generic — the exception, the path, and the caller are written to the server log against that id — so quote it when reporting one. It is not on the CORS expose list, so a cross-origin browser client cannot read it; same-origin and server-side callers can.

Limits are keyed on the client IP, except the SQL sandbox, which additionally buckets per user, and the failed sign-in limit, which counts per account (with a separate budget for each browser that has signed in to it before). The global, semantic search and basemap limits are admin settings on the Network tab, and the login limit is on the Authentication tab (LOGIN_RATE_LIMIT), so an instance you do not run may be tuned differently. The SQL sandbox limits and the per-account failed sign-in limit are fixed. The values below are the shipped defaults.

LimitDefaultApplies to
Global60/secondevery route except the tile routes and the AI routes below
Login5/minutePOST /auth/login
Failed sign-ins per account10/hourPOST /auth/login, for a client the account does not know; a browser that has signed in before has its own budget, and API keys are not affected (see Sign-in throttling)
Semantic search30/minute/search/datasets/, /search/facets/, /datasets/{id}/related/
Basemap list120/minuteGET /settings/basemaps/
SQL sandbox30/minute per user, 60/minute per IPPOST /query/

The login, semantic search, basemap and SQL sandbox limits apply on top of the global one, so a request to those routes counts against both. Tile routes are exempt from the global limit, and the AI generation and metadata routes have fixed limits of their own instead (10/minute and 20/minute).

These figures are per instance only where the instance is configured for it. From 1.19.0 the counting is shared across API workers when REDIS_URL is set, and per worker when it is not, so on a multi-worker instance without the store each limit above except the failed sign-in limit is enforced once per worker rather than once for the instance. The sign-in limit is counted in the database, so it is shared across workers either way. The same holds for as long as a configured store is unreachable. Read the table as the operator’s intent rather than as something to calibrate a client against, and back off on 429 either way.

Over any limit, global or per-route, you get a 429 in the usual problem+json envelope, with a Retry-After header giving the limiter’s window in seconds:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/problem+json

The global window is one second, so Retry-After is 1 there and a caller that hits it should pause about a second and resume. Read Retry-After and fall back to your own delay when it is missing, with jitter so a fleet of clients does not resynchronise.

Retry-After is named in Access-Control-Expose-Headers on both CORS policies — the credentialed one and the credential-free one the anonymous public routes get — so a browser client reads the retry window cross-origin instead of seeing an opaque failure.

Two limits live outside the API, in the bundled frontend nginx, which is the application edge in the standard deployment. It throttles raster tile requests at /raster-tiles/... to 600/minute per IP with a burst of 200, for anonymous and credentialed callers alike, and answers with a 503 rather than a 429. The body is nginx’s, not the API’s. It also limits vector and cluster tile requests (/api/tiles/data.* and /api/tiles/clusters/data.*) to 5400/minute with a burst of 720, and answers 429 with Retry-After: 2 and a CORS header. That limit applies only when TRUSTED_PROXY_CIDRS is set. With it empty, the default, the frontend’s entrypoint leaves the limit key blank, and nginx does not count requests with an empty key.

Every operation in the snapshot, grouped and ordered the way the sidebar is — tag groups A to Z — with the operations inside each group sorted by path. Search finds this page by method, path, or summary; the generated operation pages themselves are not indexed.

Admin (50)

Admin overview

Admin Embed Tokens (2)

Admin Embed Tokens overview

Audit (1)
Auth (20)

Auth overview

Config Ops (4)

Config Ops overview

Datasets (45)

Datasets overview

Datasets - Analysis (2)
Datasets - Data (6)

Datasets - Data overview

Datasets - Export (13)

Datasets - Export overview

Datasets - Metadata (11)

Datasets - Metadata overview

Datasets - Refresh (3)
Datasets - Reupload (6)

Datasets - Reupload overview

Datasets - Source Health (1)
Datasets - VRT (4)

Datasets - VRT overview

Embed Tokens (4)

Embed Tokens overview

Features (9)

Features overview

Health (1)
Maps (46)

Maps overview

OGC Features (13)

OGC Features overview

Query (1)
Records (14)

Records overview

STAC (9)

STAC overview

STAC Import (5)
Tiles (5)

Tiles overview