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.
Who can write
Section titled “Who can write”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.
Dataset keys
Section titled “Dataset keys”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.
| Scope | What the key can do |
|---|---|
dataset_write (default) | Read and write that dataset’s features, including the dataset’s OGC API Features items. |
dataset_append | Create features with POST /datasets/{dataset_id}/features/. Nothing else. |
Both kinds share these rules:
- Expiry.
expires_atis 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.
# 90 days out, RFC 3339.# GNU date equivalent: date -u -d '+90 days' +%Y-%m-%dT%H:%M:%SZEXPIRES=$(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.
Add-only keys
Section titled “Add-only keys”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.
In the web app
Section titled “In the web app”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.
Create features
Section titled “Create features”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.
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.
Batches
Section titled “Batches”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
featuresin the 422 response. - A feature refused while writing gets the status a single create would, with
detail.codeset tofeature_refusedanddetail.index(counting from 0) naming it. - The 201 response is a FeatureCollection of the stored features in request
order, with
table_idandtile_cache_versionon the collection too. - A batch bumps the tile cache once and writes one
feature.bulk_insertaudit entry.
Retries with Idempotency-Key
Section titled “Retries with Idempotency-Key”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-Attemptheader 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 codefeature_changedand 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-Keyis 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.
Dry run
Section titled “Dry run”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.
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_writekey 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.
Update and delete
Section titled “Update and delete”| Route | Effect |
|---|---|
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.
curl -X DELETE \ "https://geolens.example.com/api/datasets/$DATASET_ID/features/17?table_id=$TABLE_ID" \ -H "X-Api-Key: $WRITE_KEY"Delete many features
Section titled “Delete many features”POST /api/datasets/{dataset_id}/features/delete/ (the form without the
trailing slash works too) deletes 1 to 1,000 features in one request:
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”- 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.
- Dry-run one sample body with a
dataset_writekey to confirm the destination accepts it. - The webhook posts each event with an
Idempotency-Keybuilt from the event id, so a delivery retry does not add a duplicate:
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.
Retention
Section titled “Retention”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:
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_pruneaudit 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_useand thecolumn. 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.
See also
Section titled “See also”- API Authentication: key scopes, expiry and the
X-Api-Keyheader - Dataset Detail: the Data, Structure and Access tabs
- OGC API & Standards Endpoints: reading features back
- API reference: per-route schemas