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. 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.

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.

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"} 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 is specifically an upload that was never started: a presigned URL was handed out and the bytes never arrived. 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.

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 LIMIT 5",
"restrict_tables": ["world_countries"],
"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 the reason phrase for that status. 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.

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.

Two failures skip 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. The global rate-limit rejection has its own shape too; see Rate limits.

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. Every value below is an admin setting (Network tab), so an instance you do not run may be tuned differently — these are the shipped defaults.

LimitDefaultApplies to
Global60/secondevery route without its own limit
Login5/minutePOST /auth/login
Semantic search30/minute/search/datasets/, /datasets/{id}/related/
Basemap list120/minuteGET /settings/basemaps/
SQL sandbox30/minute per user, 60/minute per IPPOST /query/

A route that declares its own limit is exempt from the global one, so the two never stack.

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 is enforced once per worker rather than once for the instance. 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 a per-route limit 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

Over the global limit the answer is thinner: a 429 with Content-Type: application/json, a body of {"error": "Rate limit exceeded: 60 per 1 second"}, and no Retry-After. That window is one second, so a caller that hits it should pause about a second and resume, rather than looking for a header that will not be there. Treat any 429 as back-off; read Retry-After when present and fall back to your own delay when it is not, 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.

One limit lives outside the API: the bundled frontend nginx, which is the application edge in the standard deployment, throttles anonymous raster tile requests at /raster-tiles/... to 600/minute per IP with a burst of 200. It answers with a 503 rather than a 429, and the body is nginx’s, not the API’s.

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 (19)

Auth overview

Config Ops (4)

Config Ops overview

Datasets (41)

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 (7)

Features overview

Health (1)
Maps (45)

Maps overview

OGC Features (13)

OGC Features overview

Query (1)
Records (14)

Records overview

STAC (9)

STAC overview

STAC Import (4)
Tiles (5)

Tiles overview