Skip to content
getgeolens.com

Write features through the API

The feature routes under /api/datasets/{dataset_id}/features/ create, change and delete rows of a vector or table dataset. This page covers the write routes and the dataset-bound API keys built for them. Reads are covered by the OGC API Features endpoints. For the full request and response schemas, see the API reference.

Replace https://geolens.example.com and $DATASET_ID in the examples with your instance’s URL and the dataset’s id.

A feature write needs write access to the dataset (owner or admin) and the edit_metadata permission. It also needs Enable Dataset Editing to be on in the instance settings, which is off by default. Dataset keys are the exception to that last rule, as described below.

The dataset must have a feature table and a geometry column. Raster datasets and tables without geometry refuse feature writes.

A full-access key acts as its owner everywhere. For a webhook or a device feed that is more power than the job needs, so GeoLens offers keys bound to one dataset. Editors mint them on the dataset’s Access tab under Write keys, or through the API.

ScopeWhat the key can do
dataset_write (default)Read and write that dataset’s features, including the dataset’s OGC API Features items.
dataset_appendCreate features with POST /datasets/{dataset_id}/features/. Nothing else.

Both kinds share these rules:

  • Expiry. expires_at is required, must be in the future and at most 365 days away.
  • Acts as the minting user. The key has the permissions its creator has on the dataset. If that user loses edit rights, the key stops writing.
  • Works with editing off. Feature writes made with a dataset key go through even when Enable Dataset Editing is off. Editing in the web app, full-access keys and signed-in callers still follow that setting.
  • Invisible elsewhere. On any route its scope doesn’t cover in the table above, the key is answered exactly as an invalid key (401).
  • Limit. A dataset holds at most 100 keys that are not revoked, expired ones included. Minting past that returns 409 until you revoke one.

Minting needs a signed-in session. An API key cannot mint another key.

Terminal window
# 90 days out, RFC 3339.
# GNU date equivalent: date -u -d '+90 days' +%Y-%m-%dT%H:%M:%SZ
EXPIRES=$(python3 -c 'import datetime as d; print((d.datetime.now(d.timezone.utc) + d.timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ"))')
curl -X POST https://geolens.example.com/api/datasets/$DATASET_ID/api-keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"sensor webhook\",\"scope\":\"dataset_append\",\"expires_at\":\"$EXPIRES\"}"

The response carries the raw key in key. It is shown once and cannot be recovered. Send it in the X-Api-Key header. A dataset that cannot take feature edits (no feature table or no geometry column) answers 422.

List the dataset’s keys with GET /api/datasets/{dataset_id}/api-keys. The dataset’s owner and admins see every key bound to it; other editors see their own. Revoke one with DELETE /api/datasets/{dataset_id}/api-keys/{key_id}. Anyone with write access to the dataset can revoke any of its keys. A key’s creator can revoke it while they can still see the dataset, even after losing write access; a creator who can no longer see it asks the dataset’s owner or an admin. Revocation takes effect on the next request.

A dataset_append key authenticates only POST /datasets/{dataset_id}/features/ (with or without the trailing slash) on its dataset. Feature reads, exports, dry runs, updates and deletes all answer it as an invalid key. A leaked add-only key lets someone add rows, but not read the dataset back or rewrite it. That makes it the right key for a webhook or an IoT feed.

Because the key cannot read, its answers are masked. A create returns each feature’s id with a null geometry, empty properties, and null table_id and tile_cache_version, since a stored row can hold defaults and values the key did not send.

On the Access tab, the Write keys card lists a dataset’s keys with their creator and expiry. Create write key takes a name and an Expires in choice (30 days, 90 days or 1 year). Tick Add features only to mint an add-only key. Keys show as One dataset or One dataset, add only in this card and in the key lists under Settings and Admin > Users. See Dataset Detail.

POST /api/datasets/{dataset_id}/features/ takes one GeoJSON Feature or a FeatureCollection. The type member is optional on a single Feature; geometry is required and properties is optional.

Terminal window
curl -X POST https://geolens.example.com/api/datasets/$DATASET_ID/features/ \
-H "X-Api-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-122.4194, 37.7749]},
"properties": {"device": "42", "battery": 87}
}'

A 201 response returns the stored feature as GeoJSON with its id, plus table_id and tile_cache_version.

Send {"type": "FeatureCollection", "features": [...]} with 1 to 1,000 features to insert them in one transaction. Either every feature is written or none is.

  • A feature the schema refuses is named by its position under features in the 422 response.
  • A feature refused while writing gets the status a single create would, with detail.code set to feature_refused and detail.index (counting from 0) naming it.
  • The 201 response is a FeatureCollection of the stored features in request order, with table_id and tile_cache_version on the collection too.
  • A batch bumps the tile cache once and writes one feature.bulk_insert audit entry.

Send an Idempotency-Key header so a retried create cannot insert a second feature. The key is 1 to 128 visible ASCII characters (! to ~, no spaces), compared exactly as sent. GeoLens honors a key for 24 hours, per user and per dataset.

  • A repeat with the same key returns the stored feature with the same 201 status and body shape.
  • An optional Idempotency-Attempt header numbers the attempts 1, 2, 3 and so on (1 when omitted). A repeat with a higher attempt number applies its geometry and named properties to that feature, unless someone else changed the feature since the last attempt, in which case it answers 409 with code feature_changed and the stored feature. An equal or lower number returns the stored feature unchanged.
  • If the feature was deleted, or the dataset’s data was replaced by a reupload or overwrite, the repeat is refused with 409 rather than creating another.
  • Idempotency-Key is refused with 422 on a FeatureCollection.

Through an add-only key, a key only matches creates made with that same key, and a repeat applies nothing. It returns the same masked answer: the feature’s id and nulls. If the feature was deleted or the dataset’s data was replaced, the repeat gets 409 with code feature_gone and no tile version, and the tile cache is not touched.

POST /api/datasets/{dataset_id}/features/dry-run/ takes the body the create route takes and runs every check that route runs, including write access, the editing setting and the database’s own column casts and constraints. Nothing is stored, and the catalog entry, tile cache and audit log are left as they were. Use it to verify a webhook destination without leaving a test feature behind.

A body the create would refuse is refused with the same status and body, with one exception: a deferred constraint fails a create with a 500 at commit, while the dry run answers 400. A body that passes gets 200 with the Feature, or a FeatureCollection, as it would be stored, without an id.

Terminal window
curl -X POST https://geolens.example.com/api/datasets/$DATASET_ID/features/dry-run/ \
-H "X-Api-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{"device":"42"}}'

Rules to know:

  • A dataset_write key can dry-run its own dataset. An add-only key cannot.
  • A dry run is refused with 409 for a table where an insert could run code a rollback cannot undo. That covers any table that is not an ordinary one (a partition, for example), an enabled user trigger or rule on INSERT, a CHECK constraint, an outgoing foreign key, row security, an expression or partial index, a generated column, a user-defined column type, and a few more shapes. The 409 detail names the cause.
  • The insert still runs column defaults, so a dry run can consume sequence values and a later feature can get an id that skips some numbers.
RouteEffect
PUT /api/datasets/{dataset_id}/features/{gid}Replace the geometry and all properties. Both are required.
PATCH /api/datasets/{dataset_id}/features/{gid}Change the geometry, the named properties, or both.
DELETE /api/datasets/{dataset_id}/features/{gid}Delete the feature. Returns 204 with the new tile version in the X-GeoLens-Tile-Cache-Version header.

Each takes an optional table_id query parameter. A reupload or an overwrite replaces the dataset’s table, and the new table can reuse feature ids, so an old gid could point at a different row. Pass the table_id the feature was read with, and GeoLens refuses the write with 409 and code dataset_replaced if the table has changed since. Nothing is written in that case. Read the feature again, then retry.

Terminal window
curl -X DELETE \
"https://geolens.example.com/api/datasets/$DATASET_ID/features/17?table_id=$TABLE_ID" \
-H "X-Api-Key: $WRITE_KEY"

POST /api/datasets/{dataset_id}/features/delete/ (the form without the trailing slash works too) deletes 1 to 1,000 features in one request:

Terminal window
curl -X POST \
"https://geolens.example.com/api/datasets/$DATASET_ID/features/delete/?table_id=$TABLE_ID" \
-H "X-Api-Key: $WRITE_KEY" \
-H "Content-Type: application/json" \
-d '{"gids": [17, 18, 19]}'

The response is 200 with {"deleted": 3, "tile_cache_version": 42}. gids takes JSON integers only; true and false get 422. A duplicate gid is deleted once. The request is all or nothing. If any gid does not exist, or is outside the range the gid column can hold, the answer is 404 with detail.code feature_not_found and the missing ids in detail.gids, and nothing is deleted.

It applies the same checks as a single delete, including the optional table_id query parameter and the 409 dataset_replaced on a stale table. It works on rows that have no geometry. It writes one feature.bulk_delete audit entry and refreshes the count, extent and tile cache once. A dataset_write key can call it on its own dataset. An add-only key cannot. GET /api/datasets/{dataset_id}/rows/ returns the dataset’s current table_id, so a client that lists rows can pass it here. The delete cannot be undone.

Example: a webhook that adds a feature per event

Section titled “Example: a webhook that adds a feature per event”
  1. An editor creates an add-only key on the dataset’s Access tab, or with the API call above. The key expires within a year, so put the expiry in a calendar.
  2. Dry-run one sample body with a dataset_write key to confirm the destination accepts it.
  3. The webhook posts each event with an Idempotency-Key built from the event id, so a delivery retry does not add a duplicate:
Terminal window
curl -X POST https://geolens.example.com/api/datasets/$DATASET_ID/features/ \
-H "X-Api-Key: $APPEND_KEY" \
-H "Idempotency-Key: device-42:2026-10-17T16:09:33Z" \
-H "Content-Type: application/json" \
-d '{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-122.4194, 37.7749]},
"properties": {"device": "42", "battery": 87, "seen_at": "2026-10-17T16:09:33Z"}
}'

The answer is 201 with the new feature’s id, empty properties and null for the rest, on the first delivery and on every repeat. A 409 with feature_gone means someone deleted the feature or replaced the dataset’s data after the first delivery. If the feed posts thousands of rows a day, set retention so the dataset does not grow without bound.

A feature dataset can keep only its last N days of rows, judged by one of its date or timestamp columns. Retention is off by default.

Set it on the dataset’s Structure tab under Retention: choose the date column and the days to keep (1 to 3650). Through the API:

Terminal window
curl -X PUT https://geolens.example.com/api/datasets/$DATASET_ID/retention/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"retention": {"column": "seen_at", "days": 30}}'

Send {"retention": null} to turn it off. Add ?dry_run=true to check a setting without saving it: the response reports rows_past_cutoff, the rows that are older than the cutoff right now. In the web app, saving a setting that would delete rows right away shows that count and asks you to confirm.

How the sweep works:

  • A worker task runs hourly, at minute 23. It deletes rows whose retention column is older than the cutoff, in batches of 5,000 rows, up to 20 batches per dataset per run. A large backlog can take several runs.
  • Rows with no value in the column are kept. A timestamp without a time zone is read as UTC. A date column’s cutoff is UTC midnight.
  • Each batch commits with the feature count and tile version updated, and writes a dataset.retention_prune audit row with its count. After a run that deleted rows, the extent is recomputed.
  • The editing setting does not apply to retention. Setting it is the dataset owner’s choice, not an edit.
  • While retention reads a column, you cannot rename, retype or remove that column, or give another column its name. The schema editor and the column routes answer 409 with code retention_column_in_use and the column. Turn retention off or move it to another column first.
  • Deleted rows cannot be recovered.

Retention needs the data in an ordinary table with a whole-number gid column. It is refused with 422 when the table has child tables that inherit from it, when another table has a foreign key pointing at it, or when it has a DELETE trigger or rule.